import { z } from 'zod'; import { BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedAuctionLotSchema, NormalizedSaleSchema, extractYear, type NormalizedRecord } from '@rareindex/shared'; import { parseLots, parsePastSales, SalePayloadSchema, type PastSale, type SalePayload } from '../phillips-watches/index.js'; /** * Phillips art-side departments (Editions, Design, 20th Century & Contemporary Art, Photographs, * Jewels, Handbags). Reuses the phillips-watches page parsers; normalisation maps each lot to an * art/design/photography/jewelry/handbag sale with artist/maker as `brand`. */ const PARSER_VERSION = '1.0.0'; const DeptSchema = z.object({ filter: z.string(), category: z.string() }); export const ArtSalePayloadSchema = SalePayloadSchema.extend({ department: DeptSchema }); export type ArtSalePayload = z.infer; const LOT_CATEGORY: Array<[RegExp, string]> = [ [/photograph|gelatin silver|c-print|chromogenic|dye transfer|platinum print/i, 'photography'], [/wristwatch|rolex|patek|audemars/i, 'other_watches'], [/hermès|hermes|chanel|louis vuitton|birkin|kelly bag/i, 'luxury_handbags'], [/ring|necklace|bracelet|brooch|earrings|diamond|sapphire|emerald|ruby/i, 'jewelry'], [/chair|table|lamp|cabinet|sofa|desk|stool|vase|bench|sideboard|chandelier/i, 'design_furniture'], ]; export function categoryForLot(base: string, text: string): string { return LOT_CATEGORY.find(([re]) => re.test(text))?.[1] ?? base; } export class PhillipsArtConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 3000; async *crawl(ctx: CrawlContext): AsyncIterable { const pastUrl = String(this.meta.config.pastAuctionsUrl ?? 'https://www.phillips.com/auctions/past'); const perRun = Number(this.meta.config.salesPerRun ?? 4); const departments = z.array(DeptSchema).parse(this.meta.config.departments ?? []); const cursor = ctx.options.cursor ?? {}; const done = new Set((cursor.doneSales as string[] | undefined) ?? []); const list = await ctx.fetch(pastUrl, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 }); if (!list.success || !list.html) { ctx.anomaly('past_list_failed', list.error ?? String(list.httpStatus)); return; } const candidates: Array<{ sale: PastSale; department: z.infer }> = []; const seen = new Set(); for (const department of departments) { if (ctx.options.categories?.length && !ctx.options.categories.includes(department.category)) continue; for (const sale of parsePastSales(list.html, department.filter)) { if (seen.has(sale.saleNumber) || /watch/i.test(sale.title)) continue; if (sale.endDate && new Date(sale.endDate).getTime() > Date.now()) continue; seen.add(sale.saleNumber); candidates.push({ sale, department }); } } candidates.sort((a, b) => (b.sale.endDate ?? '').localeCompare(a.sale.endDate ?? '')); let count = 0; let fetched = 0; for (const { sale, department } of candidates) { if (ctx.signal?.aborted || this.reached(ctx, count) || fetched >= perRun) break; if (done.has(sale.saleNumber) && ctx.options.mode !== 'backfill') continue; const url = `https://www.phillips.com/auction/${sale.saleNumber}`; await this.throttle(); fetched++; const res = await ctx.fetch(url, { waitForMs: 6000, timeoutMs: 90_000, expect: ['title', 'price', 'currency'], parse: (r) => { const lots = parseLots(r.markdown ?? ''); const sold = lots.find((l) => l.soldFor); return { title: lots[0]?.maker ?? null, price: sold?.soldFor ?? null, currency: sold?.currency ?? null }; }, }); if (!res.success || !res.markdown) { ctx.anomaly('sale_fetch_failed', `${sale.saleNumber}: ${res.error ?? res.httpStatus}`); continue; } const md = res.markdown; const lotCount = Number(md.match(/##\s+(\d+)\s+Lots/)?.[1]) || null; const concluded = md.match(/Concluded\s*([A-Z][a-z]{2}\s+\d{1,2}\s+\d{4})/)?.[1] ?? null; const lotsStart = md.indexOf('## '); const payload: ArtSalePayload = { kind: 'auction_results', url, saleNumber: sale.saleNumber, title: sale.title || md.match(/^#\s+(.+)$/m)?.[1] || sale.saleNumber, location: sale.location ?? md.match(/\n(Geneva|New York|Hong Kong|London|Paris)\n/)?.[1] ?? null, startDate: sale.startDate, endDate: sale.endDate ?? (concluded ? new Date(`${concluded} UTC`).toISOString() : null), lotCount, markdown: md.slice(lotsStart >= 0 ? lotsStart : 0), department, }; const parsed = parseLots(payload.markdown); if (parsed.length === 0) { ctx.anomaly('parse_failure_lots', sale.saleNumber); continue; } if (lotCount && parsed.length < lotCount * 0.5) ctx.anomaly('partial_lot_list', `${sale.saleNumber}: ${parsed.length}/${lotCount}`); count++; done.add(sale.saleNumber); await ctx.setCursor({ doneSales: [...done].slice(-500), updatedAt: new Date().toISOString() }); yield { url, externalId: sale.saleNumber, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } } async normalize(raw: RawRecordLike): Promise { const p = ArtSalePayloadSchema.parse(raw.payload) as ArtSalePayload & SalePayload; const lots = parseLots(p.markdown); const saleDate = p.endDate ? new Date(p.endDate) : null; const out: NormalizedRecord[] = []; for (const lot of lots) { // For art lots the "maker" line is the artist; "model" holds the work title; "reference" rarely applies. const artist = lot.maker; const work = lot.model ?? lot.reference ?? null; const text = lot.lines.join(' '); const attributes = AssetAttributesSchema.parse({ categorySlug: categoryForLot(p.department.category, text), brand: artist, name: work ? `${artist} — ${work}` : artist, model: work, year: extractYear(text.replace(/\b(19|20)\d{2}\s*[–-]\s*(19|20)\d{2}\b/g, '')), identifiers: { phillips_lot: lot.url.replace(/.*\/detail\//, '') }, metadata: { sale_number: p.saleNumber, sale_title: p.title, department: p.department.filter, estimate_low: lot.estimateLow, estimate_high: lot.estimateHigh, no_reserve: lot.noReserve, lot_lines: lot.lines.slice(0, 6) }, }); const base = { connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: lot.url, externalId: `${p.saleNumber}:${lot.lotNumber ?? lot.url}`, rawTitle: work ? `${artist} · ${work}` : artist, description: null, imageUrls: lot.image ? [lot.image] : [], attributes, grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: null, conditionRaw: null, completeness: null }, observedAt: raw.fetchedAt, confidence: 0.9, parserVersion: PARSER_VERSION, }; if (lot.soldFor && lot.currency && saleDate) { out.push(NormalizedSaleSchema.parse({ kind: 'sale', ...base, saleType: 'auction', saleDate, price: lot.soldFor, currency: lot.currency, buyerPremiumIncluded: true, quantity: 1, isBundle: false, location: p.location, auctionHouse: 'Phillips', lotNumber: lot.lotNumber })); } else { out.push(NormalizedAuctionLotSchema.parse({ kind: 'auction_lot', ...base, auctionHouse: 'Phillips', auctionName: p.title, lotNumber: lot.lotNumber, startsAt: p.startDate ? new Date(p.startDate) : null, endsAt: saleDate, estimateLow: lot.estimateLow, estimateHigh: lot.estimateHigh, currentBid: null, currency: lot.currency, status: saleDate && saleDate.getTime() < Date.now() ? 'ended' : 'unknown', location: p.location })); } } return out; } } export default (meta: ConnectorMeta) => new PhillipsArtConnector(meta);