TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { z } from 'zod';2import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors';3import { AssetAttributesSchema, NormalizedAuctionLotSchema, NormalizedSaleSchema, parseSourceDate, type NormalizedRecord } from '@rareindex/shared';4import { caseSize, currencyOr, moneyNumber, watchCategory, watchMaterial, watchReferenceFromText } from '../_luxury-lib/index.js';56/** Antiquorum — catalogue lots pages (RDFa Products + 'Sold: CCY amount', premium-inclusive). */7const BASE = 'https://catalog.antiquorum.swiss';8const PARSER_VERSION = '1.0.0';910export const AuctionSchema = z.object({ slug: z.string(), id: z.string().nullable(), title: z.string().nullable(), date: z.string().nullable(), location: z.string().nullable() });11export const LotSchema = z.object({12 lotNumber: z.string(),13 name: z.string(),14 url: z.string(),15 sku: z.string().nullable(),16 image: z.string().nullable(),17 brand: z.string().nullable(),18 model: z.string().nullable(),19 reference: z.string().nullable(),20 year: z.string().nullable(),21 material: z.string().nullable(),22 diameter: z.string().nullable(),23 description: z.string().nullable(),24 estimateLow: z.number().nullable(),25 estimateHigh: z.number().nullable(),26 estimateCurrency: z.string().nullable(),27 soldPrice: z.number().nullable(),28 soldCurrency: z.string().nullable(),29 accessories: z.string().nullable(),30});31export const PagePayloadSchema = z.object({ kind: z.literal('lots_page'), url: z.string(), auction: AuctionSchema, page: z.number(), lots: z.array(LotSchema) });32export type PagePayload = z.infer<typeof PagePayloadSchema>;3334const unesc = (s: string) => s.replace(/&/g, '&').replace(/'/g, "'").replace(/"/g, '"').replace(/\s+/g, ' ').trim();3536/** Auctions listed on the catalogue home: slug (+ numeric id, date, title) per block. */37export function parseAuctionIndex(htmlText: string): z.infer<typeof AuctionSchema>[] {38 const out: z.infer<typeof AuctionSchema>[] = [];39 const seen = new Set<string>();40 const anchors = [...htmlText.matchAll(/href="\/en\/auctions\/([A-Za-z0-9_]+)\/lots"/g)];41 for (let i = 0; i < anchors.length; i++) {42 const slug = anchors[i]![1]!;43 if (seen.has(slug)) continue;44 seen.add(slug);45 const start = anchors[i]!.index!;46 const prevEnd = i > 0 ? anchors[i - 1]!.index! + anchors[i - 1]![0].length : 0;47 const end = i + 1 < anchors.length ? anchors[i + 1]!.index! : Math.min(htmlText.length, start + 4000);48 // title + date precede the lots link inside an auction card; the price-list link follows it49 const before = htmlText.slice(Math.max(prevEnd, start - 1500), start);50 const after = htmlText.slice(start, end);51 const dateRe = /([A-Z][a-z]{2,8} \d{1,2}(?:-\d{1,2})?,? 20\d\d)/g;52 const id = after.match(/\/en\/auctions\/(\d+)\/price-list/)?.[1] ?? before.match(/\/en\/auctions\/(\d+)\/price-list/)?.[1] ?? null;53 const beforeDates = [...before.matchAll(dateRe)].map((m) => m[1]!);54 const date = beforeDates[beforeDates.length - 1] ?? after.match(dateRe)?.[0] ?? null;55 const titles = [...before.matchAll(/<h[1-5][^>]*>\s*([^<]{4,90}?)\s*<\/h[1-5]>/g)].map((m) => m[1]!);56 const title = titles[titles.length - 1] ?? null;57 const loc = slug.match(/hong_kong|geneva|monaco|new_york|dubai/i)?.[0]?.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) ?? null;58 out.push({ slug, id, title: title ? unesc(title) : null, date, location: loc });59 }60 return out;61}6263export function parseLotsPage(htmlText: string, url: string, auction: z.infer<typeof AuctionSchema>, page: number): PagePayload {64 const lots: z.infer<typeof LotSchema>[] = [];65 const blocks = htmlText.split(/<h4>\s*LOT\s+/).slice(1);66 for (const b of blocks) {67 const lotNumber = b.match(/^(\d+[A-Z]?)/)?.[1];68 if (!lotNumber) continue;69 const name = b.match(/property="schema:name" content="([^"]*)"/)?.[1];70 const urlRel = b.match(/rel="schema:url" resource="([^"\s]+)/)?.[1] ?? b.match(/href="(\/en\/lots\/[^"]+)"/)?.[1];71 if (!name || !urlRel) continue;72 const spec = (label: string) => b.match(new RegExp(`<strong>${label}</strong> ([^<]{1,120})`))?.[1]?.trim() ?? null;73 const est = b.match(/N_lots_estimation'\s*>\s*([A-Z]{3})\s*([\d,]+)\s*-\s*([\d,]+)/);74 const sold = b.match(/Sold:\s*([A-Z]{3})\s*([\d,]+)/);75 lots.push({76 lotNumber,77 name: unesc(name),78 url: urlRel.startsWith('http') ? urlRel.trim() : `${BASE}${urlRel}`,79 sku: b.match(/property="schema:sku" content="([^"]*)"/)?.[1] ?? null,80 image: b.match(/rel="schema:image" resource="([^"]+)"/)?.[1] ?? null,81 brand: spec('Brand'),82 model: spec('Model'),83 reference: spec('Reference'),84 year: spec('Year'),85 material: spec('Material'),86 diameter: spec('Diameter'),87 description: b.match(/property="schema:description" content="([^"]*)"/)?.[1]?.slice(0, 500) ?? null,88 estimateLow: est ? moneyNumber(est[2]) : null,89 estimateHigh: est ? moneyNumber(est[3]) : null,90 estimateCurrency: est ? est[1]! : null,91 soldPrice: sold ? moneyNumber(sold[2]) : null,92 soldCurrency: sold ? sold[1]! : null,93 accessories: spec('Accessories')?.slice(0, 200) ?? null,94 });95 }96 return { kind: 'lots_page', url, auction, page, lots };97}9899export class AntiquorumConnector extends BaseConnector {100 readonly version = '1.0.0';101 readonly parserVersion = PARSER_VERSION;102 protected override minIntervalMs = 2000;103 override readonly urlPatterns = [/^https?:\/\/catalog\.antiquorum\.swiss\/en\/lots\/[a-z0-9-]+-lot-(\d+)-(\d+)/i];104105 async *crawl(ctx: CrawlContext): AsyncIterable<RawRecordInput> {106 const perRun = Number(this.meta.config.auctionsPerRun ?? 3);107 const maxPages = Number(this.meta.config.maxPagesPerAuction ?? 25);108 const done = new Set<string>(((ctx.options.cursor?.doneSlugs as string[] | undefined) ?? []));109 await this.throttle();110 const home = await ctx.fetch(`${BASE}/en`, { responseType: 'text', minQuality: 0.2 });111 if (!home.success || !home.html) {112 ctx.anomaly('page_fetch_failed', `home: ${home.error ?? home.httpStatus}`);113 return;114 }115 const auctions = parseAuctionIndex(home.html);116 const now = Date.now();117 // upcoming/current auctions first (they change), then past auctions not yet crawled (backfill).118 const withDate = auctions.map((a) => ({ a, t: a.date ? (parseSourceDate(a.date)?.getTime() ?? 0) : 0 }));119 const upcoming = withDate.filter((x) => x.t >= now - 3 * 86_400_000).map((x) => x.a);120 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);121 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);122 let count = 0;123 for (const auction of selected) {124 for (let page = 1; page <= maxPages; page++) {125 if (ctx.signal?.aborted || this.reached(ctx, count)) return;126 const url = `${BASE}/en/auctions/${auction.slug}/lots${page > 1 ? `?page=${page}` : ''}`;127 await this.throttle();128 const res = await ctx.fetch(url, { responseType: 'text', expect: ['title', 'price', 'date'], parse: (r) => {129 const p = r.html ? parseLotsPage(r.html, url, auction, page) : null;130 const first = p?.lots[0];131 return first ? { title: first.name, price: first.soldPrice ?? first.estimateLow, date: auction.date } : null;132 } });133 const payload = res.success && res.html ? parseLotsPage(res.html, url, auction, page) : null;134 if (!payload || payload.lots.length === 0) {135 if (page === 1) ctx.anomaly(payload ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`);136 break;137 }138 count++;139 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 };140 if (!(res.html ?? '').includes(`lots?page=${page + 1}`)) break;141 }142 const isPast = auction.date ? (parseSourceDate(auction.date)?.getTime() ?? 0) < now - 3 * 86_400_000 : false;143 if (isPast) {144 done.add(auction.slug);145 await ctx.setCursor({ doneSlugs: [...done].slice(-200) });146 }147 }148 }149150 async lookup(url: string, ctx: CrawlContext): Promise<RawRecordInput[]> {151 const m = url.match(this.urlPatterns[0]!);152 if (!m) return [];153 await this.throttle();154 const home = await ctx.fetch(`${BASE}/en`, { responseType: 'text', minQuality: 0.2 });155 const auction = home.html ? parseAuctionIndex(home.html).find((a) => a.id === m[1]) : undefined;156 if (!auction) return [];157 // lots pages hold 20 lots each; lot N sits on page ceil(N/20)158 const page = Math.max(1, Math.ceil(Number(m[2]) / 20));159 const pageUrl = `${BASE}/en/auctions/${auction.slug}/lots?page=${page}`;160 await this.throttle();161 const res = await ctx.fetch(pageUrl, { responseType: 'text', minQuality: 0.2 });162 if (!res.success || !res.html) return [];163 const payload = parseLotsPage(res.html, pageUrl, auction, page);164 payload.lots = payload.lots.filter((l) => l.url.split('?')[0] === url.split('?')[0]);165 return payload.lots.length ? [{ url: pageUrl, externalId: `${auction.slug}:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }] : [];166 }167168 async normalize(raw: RawRecordLike): Promise<NormalizedRecord[]> {169 const p = PagePayloadSchema.parse(raw.payload);170 const saleDate = p.auction.date ? parseSourceDate(p.auction.date.replace(/(\d{1,2})-\d{1,2},/, '$1,')) : null;171 const out: NormalizedRecord[] = [];172 for (const lot of p.lots) {173 const brandRaw = lot.brand ?? lot.name.split(',')[0] ?? null;174 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;175 const isJewelry = /jewel|necklace|bracelet|ring\b|earring|brooch|diamond/i.test(lot.name) && !/watch|wristwatch|chronograph/i.test(lot.name + (lot.description ?? ''));176 const categorySlug = isJewelry ? 'jewelry' : watchCategory(brand);177 const reference = lot.reference ?? watchReferenceFromText(lot.name);178 const yearMatch = lot.year?.match(/(19|20)\d{2}/)?.[0];179 const attributes = AssetAttributesSchema.parse({180 categorySlug,181 brand,182 name: `${brand ?? ''} ${lot.model ?? ''}`.trim() || lot.name,183 model: lot.model,184 reference,185 year: yearMatch ? Number(yearMatch) : null,186 material: lot.material ? (watchMaterial(lot.material) ?? lot.material.toLowerCase()) : watchMaterial(lot.name),187 size: lot.diameter ? caseSize(lot.diameter) : null,188 identifiers: { ...(reference ? { reference } : {}), antiquorum_lot: lot.sku ?? `${p.auction.id ?? p.auction.slug}-${lot.lotNumber}` },189 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 },190 });191 const accessories = (lot.accessories ?? '').toLowerCase();192 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;193 const base = {194 connectorId: this.meta.id,195 sourceId: this.meta.sourceId,196 sourceUrl: lot.url,197 externalId: lot.sku ?? `${p.auction.slug}-${lot.lotNumber}`,198 rawTitle: lot.name,199 description: lot.description,200 imageUrls: lot.image ? [lot.image] : [],201 attributes,202 condition: { condition: null, conditionRaw: null, completeness },203 observedAt: raw.fetchedAt,204 parserVersion: PARSER_VERSION,205 };206 if (lot.soldPrice && lot.soldCurrency && saleDate) {207 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 }));208 } else if (!lot.soldPrice) {209 const isPast = saleDate ? saleDate.getTime() < Date.now() - 86_400_000 : false;210 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 }));211 }212 }213 return out;214 }215}216217export default function createConnector(meta: ConnectorMeta) {218 return new AntiquorumConnector(meta);219}220