import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedAuctionLotSchema, NormalizedSaleSchema, parseSourceDate, type NormalizedRecord } from '@rareindex/shared'; import { caseSize, currencyOr, moneyNumber, watchCategory, watchMaterial, watchReferenceFromText } from '../_luxury-lib/index.js'; /** Antiquorum — catalogue lots pages (RDFa Products + 'Sold: CCY amount', premium-inclusive). */ const BASE = 'https://catalog.antiquorum.swiss'; const PARSER_VERSION = '1.0.0'; export const AuctionSchema = z.object({ slug: z.string(), id: z.string().nullable(), title: z.string().nullable(), date: z.string().nullable(), location: z.string().nullable() }); export const LotSchema = z.object({ lotNumber: z.string(), name: z.string(), url: z.string(), sku: z.string().nullable(), image: z.string().nullable(), brand: z.string().nullable(), model: z.string().nullable(), reference: z.string().nullable(), year: z.string().nullable(), material: z.string().nullable(), diameter: z.string().nullable(), description: z.string().nullable(), estimateLow: z.number().nullable(), estimateHigh: z.number().nullable(), estimateCurrency: z.string().nullable(), soldPrice: z.number().nullable(), soldCurrency: z.string().nullable(), accessories: z.string().nullable(), }); export const PagePayloadSchema = z.object({ kind: z.literal('lots_page'), url: z.string(), auction: AuctionSchema, page: z.number(), lots: z.array(LotSchema) }); export type PagePayload = z.infer; const unesc = (s: string) => s.replace(/&/g, '&').replace(/'/g, "'").replace(/"/g, '"').replace(/\s+/g, ' ').trim(); /** Auctions listed on the catalogue home: slug (+ numeric id, date, title) per block. */ export function parseAuctionIndex(htmlText: string): z.infer[] { const out: z.infer[] = []; const seen = new Set(); const anchors = [...htmlText.matchAll(/href="\/en\/auctions\/([A-Za-z0-9_]+)\/lots"/g)]; for (let i = 0; i < anchors.length; i++) { const slug = anchors[i]![1]!; if (seen.has(slug)) continue; seen.add(slug); const start = anchors[i]!.index!; const prevEnd = i > 0 ? anchors[i - 1]!.index! + anchors[i - 1]![0].length : 0; const end = i + 1 < anchors.length ? anchors[i + 1]!.index! : Math.min(htmlText.length, start + 4000); // title + date precede the lots link inside an auction card; the price-list link follows it const before = htmlText.slice(Math.max(prevEnd, start - 1500), start); const after = htmlText.slice(start, end); const dateRe = /([A-Z][a-z]{2,8} \d{1,2}(?:-\d{1,2})?,? 20\d\d)/g; const id = after.match(/\/en\/auctions\/(\d+)\/price-list/)?.[1] ?? before.match(/\/en\/auctions\/(\d+)\/price-list/)?.[1] ?? null; const beforeDates = [...before.matchAll(dateRe)].map((m) => m[1]!); const date = beforeDates[beforeDates.length - 1] ?? after.match(dateRe)?.[0] ?? null; const titles = [...before.matchAll(/]*>\s*([^<]{4,90}?)\s*<\/h[1-5]>/g)].map((m) => m[1]!); const title = titles[titles.length - 1] ?? null; const loc = slug.match(/hong_kong|geneva|monaco|new_york|dubai/i)?.[0]?.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) ?? null; out.push({ slug, id, title: title ? unesc(title) : null, date, location: loc }); } return out; } export function parseLotsPage(htmlText: string, url: string, auction: z.infer, page: number): PagePayload { const lots: z.infer[] = []; const blocks = htmlText.split(/

\s*LOT\s+/).slice(1); for (const b of blocks) { const lotNumber = b.match(/^(\d+[A-Z]?)/)?.[1]; if (!lotNumber) continue; const name = b.match(/property="schema:name" content="([^"]*)"/)?.[1]; const urlRel = b.match(/rel="schema:url" resource="([^"\s]+)/)?.[1] ?? b.match(/href="(\/en\/lots\/[^"]+)"/)?.[1]; if (!name || !urlRel) continue; const spec = (label: string) => b.match(new RegExp(`${label} ([^<]{1,120})`))?.[1]?.trim() ?? null; const est = b.match(/N_lots_estimation'\s*>\s*([A-Z]{3})\s*([\d,]+)\s*-\s*([\d,]+)/); const sold = b.match(/Sold:\s*([A-Z]{3})\s*([\d,]+)/); lots.push({ lotNumber, name: unesc(name), url: urlRel.startsWith('http') ? urlRel.trim() : `${BASE}${urlRel}`, sku: b.match(/property="schema:sku" content="([^"]*)"/)?.[1] ?? null, image: b.match(/rel="schema:image" resource="([^"]+)"/)?.[1] ?? null, brand: spec('Brand'), model: spec('Model'), reference: spec('Reference'), year: spec('Year'), material: spec('Material'), diameter: spec('Diameter'), description: b.match(/property="schema:description" content="([^"]*)"/)?.[1]?.slice(0, 500) ?? null, estimateLow: est ? moneyNumber(est[2]) : null, estimateHigh: est ? moneyNumber(est[3]) : null, estimateCurrency: est ? est[1]! : null, soldPrice: sold ? moneyNumber(sold[2]) : null, soldCurrency: sold ? sold[1]! : null, accessories: spec('Accessories')?.slice(0, 200) ?? null, }); } return { kind: 'lots_page', url, auction, page, lots }; } export class AntiquorumConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; override readonly urlPatterns = [/^https?:\/\/catalog\.antiquorum\.swiss\/en\/lots\/[a-z0-9-]+-lot-(\d+)-(\d+)/i]; async *crawl(ctx: CrawlContext): AsyncIterable { const perRun = Number(this.meta.config.auctionsPerRun ?? 3); const maxPages = Number(this.meta.config.maxPagesPerAuction ?? 25); const done = new Set(((ctx.options.cursor?.doneSlugs as string[] | undefined) ?? [])); await this.throttle(); const home = await ctx.fetch(`${BASE}/en`, { responseType: 'text', minQuality: 0.2 }); if (!home.success || !home.html) { ctx.anomaly('page_fetch_failed', `home: ${home.error ?? home.httpStatus}`); return; } const auctions = parseAuctionIndex(home.html); const now = Date.now(); // upcoming/current auctions first (they change), then past auctions not yet crawled (backfill). const withDate = auctions.map((a) => ({ a, t: a.date ? (parseSourceDate(a.date)?.getTime() ?? 0) : 0 })); const upcoming = withDate.filter((x) => x.t >= now - 3 * 86_400_000).map((x) => x.a); const past = withDate.filter((x) => x.t < now - 3 * 86_400_000 && !done.has(x.a.slug)).sort((x, y) => y.t - x.t).map((x) => x.a); const selected = ctx.options.seeds?.length ? auctions.filter((a) => ctx.options.seeds!.includes(a.slug)) : [...upcoming, ...past].slice(0, ctx.options.mode === 'backfill' ? perRun * 4 : perRun); let count = 0; for (const auction of selected) { for (let page = 1; page <= maxPages; page++) { if (ctx.signal?.aborted || this.reached(ctx, count)) return; const url = `${BASE}/en/auctions/${auction.slug}/lots${page > 1 ? `?page=${page}` : ''}`; await this.throttle(); const res = await ctx.fetch(url, { responseType: 'text', expect: ['title', 'price', 'date'], parse: (r) => { const p = r.html ? parseLotsPage(r.html, url, auction, page) : null; const first = p?.lots[0]; return first ? { title: first.name, price: first.soldPrice ?? first.estimateLow, date: auction.date } : null; } }); const payload = res.success && res.html ? parseLotsPage(res.html, url, auction, page) : null; if (!payload || payload.lots.length === 0) { if (page === 1) ctx.anomaly(payload ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } count++; yield { url, externalId: `${auction.slug}:${page}`, kind: payload.lots.some((l) => l.soldPrice) ? 'sale' : 'auction_lot', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; if (!(res.html ?? '').includes(`lots?page=${page + 1}`)) break; } const isPast = auction.date ? (parseSourceDate(auction.date)?.getTime() ?? 0) < now - 3 * 86_400_000 : false; if (isPast) { done.add(auction.slug); await ctx.setCursor({ doneSlugs: [...done].slice(-200) }); } } } async lookup(url: string, ctx: CrawlContext): Promise { const m = url.match(this.urlPatterns[0]!); if (!m) return []; await this.throttle(); const home = await ctx.fetch(`${BASE}/en`, { responseType: 'text', minQuality: 0.2 }); const auction = home.html ? parseAuctionIndex(home.html).find((a) => a.id === m[1]) : undefined; if (!auction) return []; // lots pages hold 20 lots each; lot N sits on page ceil(N/20) const page = Math.max(1, Math.ceil(Number(m[2]) / 20)); const pageUrl = `${BASE}/en/auctions/${auction.slug}/lots?page=${page}`; await this.throttle(); const res = await ctx.fetch(pageUrl, { responseType: 'text', minQuality: 0.2 }); if (!res.success || !res.html) return []; const payload = parseLotsPage(res.html, pageUrl, auction, page); payload.lots = payload.lots.filter((l) => l.url.split('?')[0] === url.split('?')[0]); return payload.lots.length ? [{ url: pageUrl, externalId: `${auction.slug}:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }] : []; } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const saleDate = p.auction.date ? parseSourceDate(p.auction.date.replace(/(\d{1,2})-\d{1,2},/, '$1,')) : null; const out: NormalizedRecord[] = []; for (const lot of p.lots) { const brandRaw = lot.brand ?? lot.name.split(',')[0] ?? null; const brand = brandRaw ? unesc(brandRaw).replace(/,\s*(switzerland|germany|france|japan|usa|england|u\.?s\.?a\.?)\s*$/i, '').replace(/\b([A-Z])([A-Z]+)\b/g, (_m, a: string, b: string) => a + b.toLowerCase()).trim() : null; const isJewelry = /jewel|necklace|bracelet|ring\b|earring|brooch|diamond/i.test(lot.name) && !/watch|wristwatch|chronograph/i.test(lot.name + (lot.description ?? '')); const categorySlug = isJewelry ? 'jewelry' : watchCategory(brand); const reference = lot.reference ?? watchReferenceFromText(lot.name); const yearMatch = lot.year?.match(/(19|20)\d{2}/)?.[0]; const attributes = AssetAttributesSchema.parse({ categorySlug, brand, name: `${brand ?? ''} ${lot.model ?? ''}`.trim() || lot.name, model: lot.model, reference, year: yearMatch ? Number(yearMatch) : null, material: lot.material ? (watchMaterial(lot.material) ?? lot.material.toLowerCase()) : watchMaterial(lot.name), size: lot.diameter ? caseSize(lot.diameter) : null, identifiers: { ...(reference ? { reference } : {}), antiquorum_lot: lot.sku ?? `${p.auction.id ?? p.auction.slug}-${lot.lotNumber}` }, metadata: { auction: p.auction.title, auction_slug: p.auction.slug, location: p.auction.location, estimate: lot.estimateLow ? { low: lot.estimateLow, high: lot.estimateHigh, currency: lot.estimateCurrency } : null, accessories: lot.accessories }, }); const accessories = (lot.accessories ?? '').toLowerCase(); const completeness = accessories ? (/box/.test(accessories) && /(certificate|papers|warranty|guarantee)/.test(accessories) ? 'full_set' : /(certificate|papers|warranty|guarantee)/.test(accessories) ? 'papers_only' : /box/.test(accessories) ? 'box_only' : null) : null; const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: lot.url, externalId: lot.sku ?? `${p.auction.slug}-${lot.lotNumber}`, rawTitle: lot.name, description: lot.description, imageUrls: lot.image ? [lot.image] : [], attributes, condition: { condition: null, conditionRaw: null, completeness }, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, }; if (lot.soldPrice && lot.soldCurrency && saleDate) { out.push(NormalizedSaleSchema.parse({ kind: 'sale', ...base, confidence: 0.92, saleType: 'auction', saleDate, price: lot.soldPrice, currency: currencyOr(lot.soldCurrency, 'CHF'), buyerPremiumIncluded: true, auctionHouse: 'Antiquorum', lotNumber: lot.lotNumber, location: p.auction.location })); } else if (!lot.soldPrice) { const isPast = saleDate ? saleDate.getTime() < Date.now() - 86_400_000 : false; out.push(NormalizedAuctionLotSchema.parse({ kind: 'auction_lot', ...base, confidence: 0.85, auctionHouse: 'Antiquorum', auctionName: p.auction.title, lotNumber: lot.lotNumber, endsAt: saleDate, estimateLow: lot.estimateLow, estimateHigh: lot.estimateHigh, currency: lot.estimateCurrency ? currencyOr(lot.estimateCurrency, 'CHF') : null, status: isPast ? 'ended' : 'upcoming', location: p.auction.location })); } } return out; } } export default function createConnector(meta: ConnectorMeta) { return new AntiquorumConnector(meta); }