import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import type { NormalizedRecord } from '@rareindex/shared'; import { amount, certFromTitle, isBundleTitle, lotAttributes, makeSale, monthYearDate, safeYear, saleGrade, sportsCategory } from '../_g7-auctions-na-lib/index.js'; const BASE = 'https://www.cleansweepauctions.com'; const HOUSE = 'Clean Sweep Auctions'; const PARSER_VERSION = '1.0.0'; const BUYERS_PREMIUM_PCT = 22; export const LotPayloadSchema = z.object({ kind: z.literal('cs_lot'), month: z.string(), monthLabel: z.string().nullable(), listUrl: z.string(), url: z.string(), slug: z.string(), title: z.string(), winningBidText: z.string().nullable(), price: z.number().nullable(), image: z.string().nullable(), description: z.string().nullable(), }); export type LotPayload = z.infer; const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']; /** "april-2025" → sortable key 202504; null when not a month slug. */ export function monthKey(slug: string): number | null { const m = slug.match(/^([a-z]+)-(\d{4})$/i); if (!m) return null; const mo = MONTHS.indexOf(m[1]!.toLowerCase()); return mo < 0 ? null : Number(m[2]) * 100 + mo + 1; } /** /past-auctions index → month slugs, newest first. */ export function parsePastIndex(htmlText: string): string[] { const $ = H.load(htmlText); const set = new Set(); $('a[href^="/past-auctions/"]').each((_, a) => { const slug = ($(a).attr('href') ?? '').replace(/^\/past-auctions\//, '').split(/[?#/]/)[0] ?? ''; if (monthKey(slug) !== null) set.add(slug.toLowerCase()); }); return [...set].sort((a, b) => monthKey(b)! - monthKey(a)!); } export interface MonthPage { month: string; monthLabel: string | null; page: number; totalPages: number | null; lots: Array<{ slug: string; url: string; title: string; image: string | null }>; } /** /past-auctions/?page=N → lot links (50 per page) + page count. */ export function parseMonthPage(htmlText: string, month: string, page: number): MonthPage { const $ = H.load(htmlText); const lots: MonthPage['lots'] = []; const seen = new Set(); const prefix = `/past-auctions/${month}/`; $('.auction-list-item').each((_, el) => { const it = $(el); const a = it.find(`a[href^="${prefix}"]`).first(); const href = a.attr('href'); const title = H.text(a); if (!href || !title) return; const slug = href.slice(prefix.length).split(/[?#]/)[0]!; if (!slug || seen.has(slug)) return; seen.add(slug); const img = it.find('img').attr('src') ?? null; lots.push({ slug, url: `${BASE}${prefix}${slug}`, title, image: img ? img.replace(/^http:/, 'https:') : null }); }); const pages = $('.pagination a[href*="page="]') .map((_, a) => Number(($(a).attr('href') ?? '').match(/page=(\d+)/)?.[1] ?? 0)) .get() .filter((n) => n > 0); return { month, monthLabel: H.text($('h1').first()), page, totalPages: pages.length ? Math.max(...pages) : null, lots }; } /** Lot page → title, "Winning bid: $X", image. */ export function parseLotPage(htmlText: string, url: string, month: string, slug: string, listUrl: string): LotPayload | null { const $ = H.load(htmlText); const title = H.text($('h1').first()) ?? H.text($('h2').first()); if (!title) return null; const text = $('body').text().replace(/\s+/g, ' '); const bid = text.match(/Winning bid:\s*(\$[\d,]+(?:\.\d{1,2})?)/i)?.[1] ?? null; const image = $('.card img').first().attr('src') ?? null; const monthLabel = H.text($('ul.crumb a[href$="' + month + '"]').first()) ?? null; const desc = H.text($('.col-md-8 > div p').first()); return { kind: 'cs_lot', month, monthLabel, listUrl, url, slug, title, winningBidText: bid, price: amount(bid), image: image ? image.replace(/^http:/, 'https:') : null, description: desc }; } const CARD_BRANDS = /\b(topps|bowman|fleer|donruss|panini|upper deck|leaf|goudey|exhibit|play ball|t206|t205|e\d{2,3}|w\d{3}|score|pro set|skybox|hoops|o-pee-chee|opc|parkhurst|red man|kellogg'?s|hostess|post cereal|bazooka|nu-card|philadelphia|sportscaster|tcma|sgc|psa|bgs|beckett)\b/i; const CARD_HINTS = /\b(rc|rookie|refractor|parallel|auto|autograph card|wax|unopened|pack|set break|cello|rack pack|checklist|#\d+|\d{4} .* \d{1,3}\b)\b/i; const MEMORABILIA_WORDS = /\b(program|ticket|stub|pennant|photo|photograph|ball|bat|jersey|glove|helmet|button|pin|magazine|yearbook|guide|book|press|schedule|poster|scorecard|contract|check|letter|menu|plate|bobble|figure|puck|stick|medal|ring|patch|cap|hat|shirt|uniform|display|sign|dinner)\b/i; /** * Clean Sweep titles are terse ("1971 Topps 556 Jim McClothlin 8", "2018 Topps Update 285 Ohtani RC Ex"): * a year + card brand + number without memorabilia words is a card even when the shared mapper cannot tell. */ export function cleanSweepCategory(title: string): string { const generic = sportsCategory(title); if (generic.endsWith('_cards') || generic === 'pokemon' || generic === 'magic_the_gathering') return generic; const cardLike = CARD_BRANDS.test(title) && (CARD_HINTS.test(title) || /\b(19|20)\d{2}\b/.test(title)) && !MEMORABILIA_WORDS.test(title); if (!cardLike) return generic; const sport = /\b(basketball|nba|jordan|lebron|kobe|hoops|skybox)\b/i.test(title) ? 'basketball_cards' : /\b(football|nfl|pro set|brady|mahomes|montana|payton)\b/i.test(title) ? 'football_cards' : /\b(hockey|nhl|o-pee-chee|opc|parkhurst|gretzky|orr|howe)\b/i.test(title) ? 'hockey_cards' : /\b(boxing|golf|tennis|wrestling|nascar|racing|olympic|soccer)\b/i.test(title) ? 'other_sports_cards' : 'baseball_cards'; return sport; } /** Bundles: "(3 pcs)", "Lot of 12", "(27 different)". */ export function isBundle(title: string): boolean { return isBundleTitle(title) || /\(\s*\d+\s*(?:pcs?|pieces|different|cards|items)\s*\)/i.test(title) || /\b\d+\s*(?:pcs?|different)\b/i.test(title); } interface Cursor { doneMonths: string[]; current: { month: string; page: number; index: number } | null; done?: boolean; } /** * Clean Sweep Auctions — past-auction archive (2008 → present). Public pages only: * /past-auctions (one entry per closed monthly auction) → /past-auctions/?page=N (50 lots) * → /past-auctions// ("Winning bid: $X", hammer before the 22 % buyer's premium). * The source only dates lots by the auction month, so saleDate is the first of that month (precision flagged). */ export class CleanSweepConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; private async html(ctx: CrawlContext, url: string): Promise<{ html: string | null; status: number | null; fetchedAt: Date }> { await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 45_000 }); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return { html: null, status: res.httpStatus, fetchedAt: res.fetchedAt }; } return { html: res.html, status: res.httpStatus, fetchedAt: res.fetchedAt }; } private readCursor(ctx: CrawlContext): Cursor { const c = ctx.options.cursor ?? {}; const cur = c.current as Cursor['current'] | undefined; return { doneMonths: Array.isArray(c.doneMonths) ? (c.doneMonths as string[]) : [], current: cur && typeof cur === 'object' && cur.month ? { month: cur.month, page: Number(cur.page ?? 1), index: Number(cur.index ?? 0) } : null }; } async *crawl(ctx: CrawlContext): AsyncIterable { const mode = ctx.options.mode; const cfg = this.meta.config; const lotsPerRun = mode === 'probe' ? (ctx.options.limit ?? 3) : Number(cfg.lotsPerRun ?? 150); const pagesPerRun = mode === 'probe' ? 1 : Number(cfg.pagesPerRun ?? 4); const cursor = this.readCursor(ctx); const done = new Set(cursor.doneMonths); // Seeds: month slugs / month URLs / lot URLs. const seedLots: Array<{ month: string; slug: string }> = []; const seedMonths: string[] = []; for (const s of ctx.options.seeds ?? []) { const m = s.match(/past-auctions\/([a-z]+-\d{4})(?:\/([^/?#]+))?/i) ?? s.match(/^([a-z]+-\d{4})$/i); if (!m) continue; if (m[2]) seedLots.push({ month: m[1]!.toLowerCase(), slug: m[2] }); else seedMonths.push(m[1]!.toLowerCase()); } let count = 0; let items = 0; for (const sl of seedLots) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const listUrl = `${BASE}/past-auctions/${sl.month}`; const url = `${listUrl}/${sl.slug}`; const r = await this.html(ctx, url); const payload = r.html ? parseLotPage(r.html, url, sl.month, sl.slug, listUrl) : null; if (!payload) continue; count++; yield { url, externalId: `${sl.month}/${sl.slug}`, kind: 'sale', engine: 'api', httpStatus: r.status, payload, fetchedAt: r.fetchedAt }; } if (seedLots.length && !seedMonths.length) return; let months = seedMonths; if (!months.length) { const idx = await this.html(ctx, `${BASE}/past-auctions`); if (!idx.html) return; months = parsePastIndex(idx.html); if (!months.length) { ctx.anomaly('selector_missing', '/past-auctions: no month links found'); return; } } // Backfill and incremental both walk newest → oldest; incremental stops after the first (newest) pending month. const pending = months.filter((m) => !done.has(m)); const queue = cursor.current && pending.includes(cursor.current.month) ? [cursor.current.month, ...pending.filter((m) => m !== cursor.current!.month)] : pending; if (!queue.length) { if (mode === 'backfill') await ctx.setCursor({ doneMonths: [...done], current: null, done: true, updatedAt: new Date().toISOString() }); return; } const monthsThisRun = mode === 'incremental' ? queue.slice(0, 1) : queue; let pagesFetched = 0; let reachedDate: Date | null = null; for (const month of monthsThisRun) { if (ctx.signal?.aborted || this.reached(ctx, count) || items >= lotsPerRun || pagesFetched >= pagesPerRun) break; const listUrl = `${BASE}/past-auctions/${month}`; let page = cursor.current?.month === month ? cursor.current.page : 1; let index = cursor.current?.month === month ? cursor.current.index : 0; let monthComplete = false; while (!ctx.signal?.aborted && pagesFetched < pagesPerRun) { const r = await this.html(ctx, page > 1 ? `${listUrl}?page=${page}` : listUrl); pagesFetched++; if (!r.html) break; const mp = parseMonthPage(r.html, month, page); if (mp.lots.length === 0 && page === 1) ctx.anomaly('selector_missing', `${listUrl}: no lots found`); let stop = false; for (let i = index; i < mp.lots.length; i++) { if (ctx.signal?.aborted || this.reached(ctx, count) || items >= lotsPerRun) { stop = true; break; } const lot = mp.lots[i]!; const r2 = await this.html(ctx, lot.url); index = i + 1; await ctx.setCursor({ doneMonths: [...done], current: { month, page, index }, updatedAt: new Date().toISOString() }); if (!r2.html) continue; const payload = parseLotPage(r2.html, lot.url, month, lot.slug, listUrl); if (!payload) { ctx.anomaly('parse_failure_page', lot.url); continue; } if (!payload.image && lot.image) payload.image = lot.image; if (payload.price === null) ctx.anomaly('price_parse_failure', `${lot.url}: ${payload.winningBidText ?? 'no winning bid'}`); count++; items++; yield { url: lot.url, externalId: `${month}/${lot.slug}`, kind: 'sale', engine: 'api', httpStatus: r2.status, payload, fetchedAt: r2.fetchedAt }; } if (stop) break; const last = mp.totalPages !== null ? page >= mp.totalPages : mp.lots.length < 50; if (last) { monthComplete = true; break; } page++; index = 0; await ctx.setCursor({ doneMonths: [...done], current: { month, page, index }, updatedAt: new Date().toISOString() }); } if (monthComplete) { done.add(month); reachedDate = monthYearDate(month); await ctx.setCursor({ doneMonths: [...done], current: null, updatedAt: new Date().toISOString() }); } await ctx.progress({ page: months.filter((m) => done.has(m)).length, totalPages: months.length, itemsProcessed: items, reachedDate, cursor: { doneMonths: [...done], current: monthComplete ? null : { month, page, index } } }); } if (mode === 'backfill' && months.every((m) => done.has(m))) await ctx.setCursor({ doneMonths: [...done], current: null, done: true, updatedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = LotPayloadSchema.parse(raw.payload); if (p.price === null || p.price <= 0) return []; const saleDate = monthYearDate(p.month); if (!saleDate) return []; const g = saleGrade(p.title); const graded = Boolean(g.grader && g.grade); const slug = /\b(signed|autograph)/i.test(p.title) && !/\b(baseball|football|basketball|hockey|boxing|golf|tennis|nascar|wrestling|topps|bowman|fleer|card|ball|bat|jersey|program|ticket|photo)\b/i.test(p.title) ? 'autographs' : cleanSweepCategory(p.title); const attributes = lotAttributes({ categorySlug: slug, name: p.title, year: safeYear(p.title), identifiers: { clean_sweep_lot_slug: `${p.month}/${p.slug}` }, metadata: { auction_month: p.month, auction_label: p.monthLabel, sale_date_precision: 'month', buyers_premium_pct: BUYERS_PREMIUM_PCT, hammer_price: p.price } }); const sale = makeSale({ meta: this.meta, sourceUrl: p.url, externalId: `${p.month}/${p.slug}`, rawTitle: p.title, description: p.description, attributes, price: p.price, currency: 'USD', saleDate, buyerPremiumIncluded: false, auctionHouse: HOUSE, imageUrls: p.image ? [p.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader, grade: g.grade, location: 'US', isBundle: isBundle(p.title), confidence: graded ? 0.75 : 0.7 }); sale.grade.qualifier = g.qualifier; sale.grade.certificationNumber = certFromTitle(p.title); return [sale]; } } export default (meta: ConnectorMeta) => new CleanSweepConnector(meta);