import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared'; import { makeSale } from '../../firecrawl/_carlib/index.js'; import { clean, isNumisBundle, numisAttributes, parseAuctionDate, realized } from '../../firecrawl/_g5-numismatics-lib/index.js'; /** * Corinphila Auctions (Zürich) — Switzerland's oldest stamp auction house. The archive on corinphila.ch * (c4ms platform) is static HTML: auction overview (catalogue parts with lot counts, c4msEnv.auctionData * JSON with currency and dates) → lot list pages of 100 lots with country, description, starting bid and * "Hammer price : 260.00 CHF" (or "not sold"). Live catalogues (auction.corinphila.ch) are JS/ajax and * are not used. robots: Crawl-Delay 10 → 10 s between requests. */ const SITE = 'https://corinphila.ch'; const PARSER_VERSION = '1.0.0'; export const AuctionSchema = z.object({ id: z.string(), name: z.string(), currency: z.string().nullable(), startDate: z.string().nullable(), endDate: z.string().nullable(), status: z.string().nullable() }); export const PartSchema = z.object({ catalogPart: z.string(), title: z.string().nullable(), lotCount: z.number().int().nullable() }); export const LotSchema = z.object({ lotNo: z.string(), country: z.string().nullable(), description: z.string(), startText: z.string().nullable(), hammerText: z.string().nullable(), conditionCodes: z.array(z.string()).default([]), images: z.array(z.string()).default([]), }); export const PayloadSchema = z.object({ kind: z.literal('lots_page'), auction: AuctionSchema, part: PartSchema, url: z.string(), page: z.number().int(), totalPages: z.number().int().nullable(), lots: z.array(LotSchema) }); export type Payload = z.infer; /** Archive page (…&action=show&id=211) → auction ids with printed names/dates. */ export function parseArchive(htmlText: string): Array<{ id: string; name: string; dateText: string | null }> { const $ = H.load(htmlText); const out: Array<{ id: string; name: string; dateText: string | null }> = []; $('.auctionBox').each((_, box) => { const e = $(box); const id = e.find('a[href*="showAuctionOverview"]').first().attr('href')?.match(/auctionID=(\d+)/)?.[1]; if (!id || out.some((a) => a.id === id)) return; const name = clean(e.find('h4').first().text()); const dateText = clean(e.find('.date').first().text()) || null; if (name) out.push({ id, name, dateText }); }); return out; } /** Auction overview → auctionData JSON + catalogue parts ("Show all lots" links carry the lot count). */ export function parseOverview(htmlText: string, id: string): { auction: z.infer; parts: z.infer[] } | null { const m = htmlText.match(/c4msEnv\.auctionData\s*=\s*(\{[\s\S]*?\});/); let data: { currency?: string; startDate?: string; endDate?: string; status?: string; name?: string } = {}; if (m) { try { data = JSON.parse(m[1]!) as typeof data; } catch { data = {}; } } const $ = H.load(htmlText); const name = clean(data.name ?? '') || clean($('h1').first().text()); if (!name) return null; const parts: z.infer[] = []; $('.bookmark_left_red').each((_, hdr) => { const title = clean($(hdr).find('h3').first().text()) || null; const box = $(hdr).nextAll('.countryBox').first(); const link = box.find(`a[href*="action=showLots"][href*="auctionID=${id}"][href*="show_all_lots=1"]`).first(); const cp = link.attr('href')?.match(/catalogPart=(\d+)/)?.[1]; if (!cp || parts.some((p) => p.catalogPart === cp)) return; const count = Number(clean(box.find('a.lotCounter').first().text()).replace(/\D/g, '')); parts.push({ catalogPart: cp, title, lotCount: Number.isFinite(count) && count > 0 ? count : null }); }); return { auction: { id, name, currency: data.currency ?? null, startDate: data.startDate ?? null, endDate: data.endDate ?? null, status: data.status ?? null }, parts }; } export function lotsUrl(auctionId: string, catalogPart: string, page: number): string { return `${SITE}/en/_auctions/&action=showLots&auctionID=${auctionId}&catalogPart=${catalogPart}&show_all_lots=1&page=${page}`; } /** Lot list page → lots + page count. */ export function parseLotsPage(htmlText: string, auction: z.infer, part: z.infer, url: string): Payload { const $ = H.load(htmlText); const page = Number(url.match(/[?&]page=(\d+)/)?.[1] ?? 1); const totalPagesText = clean($('.pageCounterLabel').first().text()); const lots: z.infer[] = []; $('div.lot[data-lotno], div.lot[data-lotNo]').each((_, el) => { const e = $(el); const lotNo = e.attr('data-lotno') ?? e.attr('data-lotNo') ?? clean(e.find('.lotno').first().text()); const description = clean(e.find('.lotDesc .text').first().text()); if (!lotNo || !description) return; const start = clean(e.find('.prices .start .value').first().text()) || null; const hammer = clean(e.find('.prices .bid .value').first().text()) || null; const images = e.find('.picContainer img[data-src]').map((__, img) => $(img).attr('data-src') ?? '').get().filter(Boolean); const conditionCodes = e.find('.lot-cond').map((__, c) => ($(c).attr('class') ?? '').match(/lot-cond-(\d+)/)?.[1] ?? '').get().filter(Boolean); lots.push({ lotNo, country: clean(e.find('.lotCountry').first().text()) || null, description, startText: start, hammerText: hammer, conditionCodes, images: images.slice(0, 3) }); }); return { kind: 'lots_page', auction, part, url, page, totalPages: totalPagesText ? Number(totalPagesText) : null, lots }; } interface Cursor { doneAuctions?: string[]; inProgress?: { auction: z.infer; parts: z.infer[]; partIndex: number; page: number } | null; done?: boolean; updatedAt?: string; } export class CorinphilaConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 10_000; async *crawl(ctx: CrawlContext): AsyncIterable { const pagesPerRun = Number(this.meta.config.pagesPerRun ?? 20); const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 1); const cursor = { ...((ctx.options.cursor ?? {}) as Cursor) }; const done = new Set(cursor.doneAuctions ?? []); let pages = 0; let yielded = 0; let finished = 0; const text = (url: string, expect: Parameters[1] = {}) => ctx.fetch(url, { engines: ['api', 'firecrawl'], responseType: 'text', minQuality: 0, ...expect }); await this.throttle(); const archive = await text(`${SITE}/en/_pages/&action=show&id=211`); pages++; const auctions = archive.success && archive.html ? parseArchive(archive.html) : []; if (!auctions.length) { ctx.anomaly(archive.success ? 'selector_missing' : 'page_fetch_failed', `archive: ${archive.error ?? archive.httpStatus ?? 'no auction boxes'}`); return; } auctions.sort((a, b) => Number(b.id) - Number(a.id)); const queue = [...(cursor.inProgress ? [cursor.inProgress.auction.id] : []), ...auctions.map((a) => a.id).filter((id) => !done.has(id) && id !== cursor.inProgress?.auction.id)]; for (const id of queue) { if (ctx.signal?.aborted || finished >= auctionsPerRun || pages >= pagesPerRun || this.reached(ctx, yielded)) break; let state = cursor.inProgress?.auction.id === id ? cursor.inProgress : null; if (!state) { await this.throttle(); const ov = await text(`${SITE}/en/_auctions/&action=showAuctionOverview&auctionID=${id}`); pages++; const parsed = ov.success && ov.html ? parseOverview(ov.html, id) : null; if (!parsed || !parsed.parts.length) { ctx.anomaly(ov.success ? 'parse_failure_page' : 'page_fetch_failed', `overview ${id}: ${ov.error ?? ov.httpStatus ?? 'no catalogue parts'}`); if (ov.success) done.add(id); continue; } if (parsed.auction.status && parsed.auction.status !== 'closed') continue; // running sale state = { auction: parsed.auction, parts: parsed.parts, partIndex: 0, page: 1 }; } while (state.partIndex < state.parts.length && pages < pagesPerRun && !ctx.signal?.aborted && !this.reached(ctx, yielded)) { const part = state.parts[state.partIndex]!; const url = lotsUrl(id, part.catalogPart, state.page); await this.throttle(); const res = await text(url, { expect: ['title', 'price'], parse: (r) => { const p = r.html ? parseLotsPage(r.html, state!.auction, part, url) : null; return p?.lots.length ? { title: p.lots[0]!.description, price: p.lots.find((l) => l.hammerText && !/not sold/i.test(l.hammerText))?.hammerText ?? null } : null; }, }); pages++; if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } const payload = parseLotsPage(res.html, state.auction, part, url); if (!payload.lots.length && state.page === 1) ctx.anomaly('parse_failure_page', `${url}: no lot blocks`); if (payload.lots.some((l) => l.hammerText && !/not sold/i.test(l.hammerText))) { yielded++; yield { url, externalId: `auction:${id}:part:${part.catalogPart}:p${payload.page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } const more = payload.lots.length > 0 && payload.totalPages !== null && payload.page < payload.totalPages; if (more) state.page++; else { state.partIndex++; state.page = 1; } cursor.inProgress = state; await ctx.setCursor({ ...cursor, doneAuctions: [...done].slice(-100), updatedAt: new Date().toISOString() }); } if (state.partIndex >= state.parts.length) { done.add(id); finished++; cursor.inProgress = null; if (ctx.options.mode === 'backfill') await ctx.progress({ page: auctions.findIndex((a) => a.id === id) + 1, totalPages: auctions.length, itemsProcessed: yielded, reachedDate: parseAuctionDate(state.auction.startDate) }); } await ctx.setCursor({ ...cursor, doneAuctions: [...done].slice(-100), updatedAt: new Date().toISOString() }); } if (ctx.options.mode === 'backfill' && auctions.every((a) => done.has(a.id))) await ctx.setCursor({ ...cursor, done: true, doneAuctions: [...done].slice(-100), updatedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const saleDate = parseAuctionDate(p.auction.startDate); if (!saleDate) return []; const out: NormalizedSale[] = []; for (const lot of p.lots) { if (!lot.hammerText || /not sold|withdrawn/i.test(lot.hammerText)) continue; const price = realized(lot.hammerText, (p.auction.currency as 'CHF' | null) ?? 'CHF'); if (!price) continue; const start = realized(lot.startText, price.currency); const title = lot.country ? `${lot.country}: ${lot.description}` : lot.description; const attributes = numisAttributes({ categorySlug: 'stamps', title, section: p.part.title, identifiers: { corinphila_lot: `${p.auction.id}/${lot.lotNo}` }, metadata: { auction_id: p.auction.id, auction_name: p.auction.name, catalogue_part: p.part.title, country_label: lot.country, starting_bid: start?.amount ?? null, hammer_price: price.amount, buyer_premium: "excluded — page label 'Hammer price'; Corinphila's premium is stated in the conditions of sale", condition_codes: lot.conditionCodes, michel_or_sg: lot.description.match(/\b(?:SG|Mi\.?|Michel|Zumstein|Yv\.?|Scott)\s*\d+[a-z]?/i)?.[0] ?? null }, }); const sale = makeSale({ meta: this.meta, sourceUrl: `${SITE}/en/_auctions/&action=showLot&auctionID=${p.auction.id}&lotno=${lot.lotNo}`, externalId: `${p.auction.id}-${lot.lotNo}`, rawTitle: title.length > 240 ? `${title.slice(0, 239)}…` : title, description: lot.description, attributes, price: price.amount, currency: price.currency, saleDate, buyerPremiumIncluded: false, auctionHouse: 'Corinphila Auctions', lotNumber: lot.lotNo, imageUrls: lot.images, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, confidence: 0.85, isBundle: isNumisBundle(lot.description) || /\b(collection|accumulation|lot of|group of|balance)\b/i.test(lot.description), location: 'CH', }); out.push(sale); } return out; } } export default (meta: ConnectorMeta) => new CorinphilaConnector(meta);