import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { parseGradeFromTitle } from '@rareindex/taxonomy'; import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared'; import { dateWords, lotAttributes, makeSale, money } from '../../firecrawl/_carlib/index.js'; const BASE = 'https://www.swanngalleries.com'; const PARSER_VERSION = '1.0.0'; /** Swann answers 403 to bare product tokens; a Mozilla-compatible bot UA (still identifying RareIndex) is accepted. */ const UA = { 'user-agent': 'Mozilla/5.0 (compatible; RareIndexBot/0.1; +https://www.rareindex.io/about)' }; export const AuctionSchema = z.object({ slug: z.string(), url: z.string(), title: z.string(), dateText: z.string().nullable(), department: z.string().nullable(), saleNumber: z.string().nullable() }); export const LotSchema = z.object({ ref: z.string(), url: z.string(), lotNumber: z.string().nullable(), title: z.string(), estimateText: z.string().nullable(), soldText: z.string().nullable(), passed: z.boolean(), premiumNote: z.boolean(), image: z.string().nullable() }); export const PagePayloadSchema = z.object({ kind: z.literal('catalog_page'), auction: AuctionSchema, page: z.number(), lots: z.array(LotSchema) }); export type PagePayload = z.infer; export function parsePastAuctions(htmlText: string): z.infer[] { const $ = H.load(htmlText); const out: z.infer[] = []; $('a.btn-cta--view_lots').each((_, a) => { const href = $(a).attr('href') ?? ''; const m = href.match(/auction-catalog\/([^/?#]+)/); if (!m) return; // walk up to the nearest ancestor that carries the event title (markup nests the CTA deep inside the card) const card = $(a).parents().filter((_, el) => $(el).find('.event__title').length > 0).first(); const scope = card.length ? card : $(a).parent(); const title = H.text(scope.find('.event__title').first()) ?? H.text(scope.find('h2').first()) ?? ''; const dateText = H.text(scope.find('.event__date').first()); const dept = H.text(scope.find('.event__department a').first()); const saleNumber = (H.text(scope.find('.event__department').first()) ?? '').match(/Sale\s+(\d+)/)?.[1] ?? null; if (title && !out.some((x) => x.slug === m[1])) out.push({ slug: m[1]!, url: href, title, dateText, department: dept, saleNumber }); }); return out; } export function parseCatalogPage(htmlText: string, auction: z.infer, page: number): PagePayload { const $ = H.load(htmlText); const lots: z.infer[] = []; $('[data-lot-ref]').each((_, el) => { const $el = $(el); const ref = $el.attr('data-lot-ref') ?? ''; const a = $el.find('a[href*="/auction-lot/"]').first(); const url = a.attr('href') ?? ''; const titleRaw = H.text($el.find('[class*="card-title"]').first()) ?? ''; const tm = titleRaw.match(/^(\d+[A-Za-z]?):\s*(.*)$/s); const estimateText = $el.find('[class*="estimate-bid"] span').last().text().trim() || null; const amount = $el.find('[class*="bid-amount"] [class*="amount"] span').last().text().trim(); const passed = /passed|unsold|withdrawn/i.test($el.text()); if (!ref || !url || !titleRaw) return; lots.push({ ref, url, lotNumber: tm?.[1] ?? null, title: (tm?.[2] ?? titleRaw).trim(), estimateText, soldText: amount && /\d/.test(amount) ? amount : null, passed, premiumNote: /includes buyer/i.test($el.text()), image: $el.find('img').first().attr('src') ?? null }); }); return { kind: 'catalog_page', auction, page, lots }; } export function swannCategory(department: string | null, saleTitle: string, lotTitle: string): string { const d = `${department ?? ''} ${saleTitle}`.toLowerCase(); const t = lotTitle.toLowerCase(); if (/autograph/.test(d)) return /letter|document|manuscript|signed document|archive/.test(t) ? 'historical_documents' : 'autographs'; if (/photograph/.test(d)) return 'photography'; if (/poster/.test(d)) return 'movie_posters'; if (/map|atlas/.test(d)) return 'maps'; if (/illustration|animation|comic/.test(d)) return /cel\b|animation/.test(t) ? 'animation_art' : 'art'; if (/contemporary|modern|african-american art|19th|20th|prints|drawings|art/.test(d)) return /contemporary|post-war/.test(d) ? 'contemporary_art' : 'art'; if (/printed|manuscript|americana|books|literature|children|early printed/.test(d)) return /letter|manuscript|document|archive|autograph/.test(t) ? 'historical_documents' : 'books'; return 'books'; } export class SwannConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 1500; async *crawl(ctx: CrawlContext): AsyncIterable { const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 2); const pagesPerAuction = Number(this.meta.config.pagesPerAuction ?? 15); const done = new Set(Array.isArray(ctx.options.cursor?.doneAuctions) ? (ctx.options.cursor!.doneAuctions as string[]) : []); const listUrl = `${BASE}/auctions/past-auctions/`; await this.throttle(); const list = await ctx.fetch(listUrl, { engines: ['api', 'firecrawl'], headers: UA, responseType: 'text', expect: ['title', 'date'], parse: (r) => (r.html ? { title: parsePastAuctions(r.html)[0]?.title ?? null, date: parsePastAuctions(r.html)[0]?.dateText ?? null } : null) }); if (!list.success || !list.html) { ctx.anomaly('page_fetch_failed', `${listUrl}: ${list.error ?? list.httpStatus}`); return; } const auctions = parsePastAuctions(list.html).filter((a) => !done.has(a.slug)); let count = 0; let processed = 0; for (const auction of auctions) { if (processed >= auctionsPerRun || ctx.signal?.aborted) break; for (let page = 1; page <= pagesPerAuction; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) break; const url = `${BASE}/auction-catalog/${auction.slug}?algoliaParam=${encodeURIComponent(`archive_lotNumber_asc_prod[page]=${page}`)}`; await this.throttle(); const res = await ctx.fetch(url, { engines: ['api', 'firecrawl'], headers: UA, responseType: 'text', expect: ['title', 'price', 'status'], parse: (r) => { const p = r.html ? parseCatalogPage(r.html, auction, page) : null; const f = p?.lots.find((l) => l.soldText); return f ? { title: f.title, price: money(f.soldText, 'USD')?.amount ?? null, status: 'sold' } : p?.lots.length ? { title: p.lots[0]!.title } : null; }, minQuality: 0.2, }); if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } const payload = parseCatalogPage(res.html, auction, page); if (payload.lots.length === 0) break; count++; yield { url, externalId: `catalog:${auction.slug}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; if (payload.lots.length < 20) break; } processed++; done.add(auction.slug); await ctx.setCursor({ doneAuctions: [...done].slice(-300), updatedAt: new Date().toISOString() }); } } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const saleDate = dateWords(p.auction.dateText); if (!saleDate) return []; const out: NormalizedSale[] = []; for (const lot of p.lots) { if (!lot.soldText || lot.passed) continue; const m = money(lot.soldText, 'USD'); if (!m) continue; const categorySlug = swannCategory(p.auction.department, p.auction.title, lot.title); const g = parseGradeFromTitle(lot.title); const year = lot.title.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1]; const artist = lot.title.match(/^([A-Z][A-Za-z.'\- ]+?)(?:\.|,|\s\()/)?.[1] ?? null; const attributes = lotAttributes({ categorySlug, name: lot.title, brand: artist, year: year ? Number(year) : null, identifiers: { swann_lot_ref: lot.ref }, metadata: { sale_number: p.auction.saleNumber, sale_title: p.auction.title, department: p.auction.department, estimate: lot.estimateText } }); out.push(makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: lot.ref, rawTitle: lot.title, attributes, price: m.amount, currency: 'USD', saleDate, buyerPremiumIncluded: lot.premiumNote ? true : null, auctionHouse: 'Swann Auction Galleries', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader && g.grader !== 'raw' ? g.grader : null, grade: g.grader && g.grader !== 'raw' ? g.grade : null, location: 'US' })); } return out; } } export default (meta: ConnectorMeta) => new SwannConnector(meta);