import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors'; import type { ExtractionResult } from '@rareindex/shared'; import { parseEuDate, parseEuMoney } from '../_g8-auctions-eu-apac-lib/index.js'; import { SaleResultsConnector, absolute, chunksBetween, pick, textOf, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js'; /** * Millon (Paris, Bruxelles, Nice…) — Drupal site, fully server-rendered. Past sales: /catalogue/ventes-passees?page=N * (0-based, 36 cards/page, ~1 740 sales); sale page /catalogue/vente-?page=N (0-based, 50 lots/page) with * cards "Lot 12 · title · sub-title · description · Adjugé à | 160 €". The sale year is not printed on the sale * page ("lun 06 Juil - lun 07 Sep"); each lot page states "Vente du 6 juillet 2026", so the connector reads * the first lot page of a sale once to get the date. "Adjugé à" is not labelled hammer/with-fees → premium basis null. */ const BASE = 'https://www.millon.com'; const PAGE = 50; export function parseMillonIndex(htmlText: string): SaleRef[] { const out: SaleRef[] = []; const seen = new Set<string>(); for (const chunk of chunksBetween(htmlText, /<article class="card[^"]*">/)) { const href = chunk.match(/href="(\/catalogue\/vente(\d+)[^"/?#]*)"/); if (!href || seen.has(href[2]!)) continue; seen.add(href[2]!); const title = pick(chunk, /<h5 class="card-title"><a[^>]*>([\s\S]*?)<\/a>/) ?? pick(chunk, /title="([^"]+)"/) ?? href[2]!; const dayText = pick(chunk, /day-of-sale">([\s\S]*?)<\/span>/); const location = pick(chunk, /selling-location">([\s\S]*?)<\/span>/); out.push({ id: href[2]!, title, url: `${BASE}${href[1]}`, date: null, location: location || null, extra: { day_text: dayText } }); } return out; } /** Total pages from the Drupal pagerer widget (`max="49"` → 49 pages, 0-based query). */ export function parsePagerMax(htmlText: string): number | null { const m = htmlText.match(/class="pagerer-page"[^>]*max="(\d+)"/); return m ? Number(m[1]) : null; } /** Lot page → sale date ("Vente <a>…</a> du 6 juillet 2026"). */ export function parseLotPageDate(htmlText: string): string | null { const m = htmlText.match(/sale--info">[\s\S]*?<\/a>\s*(?:du|le|des)\s+([^<]+?)\s*<\/p>/); const d = m ? parseEuDate(m[1]!) : null; return d ? d.toISOString() : null; } export function parseMillonSalePage(htmlText: string, sale: SaleRef, page: number): ParsedSalePage | null { if (!/class="card[^"]*"/.test(htmlText) || !/<span class="lot">/.test(htmlText)) return null; const lots: ParsedLot[] = []; for (const chunk of chunksBetween(htmlText, /<article class="card[^"]*">/)) { const lotNo = pick(chunk, /<span class="lot">\s*Lot\s*([^<]*)<\/span>/); const href = chunk.match(/href="(\/catalogue\/vente\d+[^"]*\/lot\d+[^"]*)"/)?.[1] ?? null; const title = pick(chunk, /<h5 class="card-title"><a[^>]*>([\s\S]*?)<\/a>/); if (!lotNo || !title || !href) continue; const sub = pick(chunk, /<p class="sub-title">([\s\S]*?)<\/p>/); const desc = pick(chunk, /<div class="description">([\s\S]*?)<\/div>/); const est = chunk.match(/<span class="label">\s*Estimation[^<]*<\/span>\s*([^<]+)</); const adj = chunk.match(/<span class="label">\s*Adjugé[^<]*<\/span>\s*([^<]+)</); const price = adj ? parseEuMoney(textOf(adj[1]), 'EUR') : null; const estM = est ? textOf(est[1]).match(/([\d\s.,]+)\s*€?\s*-\s*([\d\s.,]+)\s*€/) : null; const image = chunk.match(/srcset="(\/sites\/millon\/files\/[^"\s]+)/)?.[1] ?? null; lots.push({ lotNo: lotNo.replace(/\s+/g, ''), title, subtitle: sub || null, description: desc || null, url: absolute(BASE, href)!, image: image ? absolute(BASE, image) : null, price: price?.amount ?? null, currency: 'EUR', premiumIncluded: null, estimateLow: estM ? parseEuMoney(`${estM[1]} €`, 'EUR')?.amount ?? null : null, estimateHigh: estM ? parseEuMoney(`${estM[2]} €`, 'EUR')?.amount ?? null : null, date: null, sold: price !== null, extra: { adjuge_text: adj ? textOf(adj[1]) : null }, }); } const max = parsePagerMax(htmlText); const title = pick(htmlText, /<h1 class="title-large">([\s\S]*?)<\/h1>/); const type = pick(htmlText, /<p class="type">([\s\S]*?)<\/p>/); const location = pick(htmlText, /<span class="sale-status">([\s\S]*?)<\/span>/) ?? pick(htmlText, /selling-location">([\s\S]*?)<\/span>/); return { lots, hasMore: max !== null ? page < max : lots.length >= PAGE, totalLots: null, sale: { title: title ?? undefined, location: location || undefined, extra: { sale_type: type ?? null, day_text: pick(htmlText, /day-of-sale">([\s\S]*?)<\/span>/) } } }; } export class MillonConnector extends SaleResultsConnector { readonly version = '1.0.0'; readonly house: HouseConfig = { houseName: 'Millon', defaultCurrency: 'EUR', location: 'Paris, France', idKey: 'millon_lot', premiumIncluded: null, fallbackSlug: 'antiques', minIntervalMs: 2500, maxPagesPerSale: 30 }; protected override minIntervalMs = 2500; async listSales(ctx: CrawlContext): Promise<SaleRef[]> { const pages = ctx.options.mode === 'backfill' ? Number(this.meta.config.backfillIndexPages ?? 60) : Number(this.meta.config.indexPages ?? 1); const all: SaleRef[] = []; for (let p = 0; p < pages; p++) { const url = `${BASE}/catalogue/ventes-passees${p > 0 ? `?page=${p}` : ''}`; await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 }); if (!res.success || !res.html) { ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } const sales = parseMillonIndex(res.html); if (sales.length === 0) break; // keep index order (most recent first) as a pseudo-date so incremental runs pick newest sales first for (const s of sales) all.push({ ...s, extra: { ...s.extra, index_rank: all.length } }); const max = parsePagerMax(res.html); if (max !== null && p + 1 >= max) break; } return all; } salePageUrl(sale: SaleRef, page: number): string { return page > 1 ? `${sale.url}?page=${page - 1}` : sale.url; } parseSalePage(res: ExtractionResult, sale: SaleRef, page: number): ParsedSalePage | null { return res.html ? parseMillonSalePage(res.html, sale, page) : null; } /** Page 1 also fetches the first lot page to learn the sale date (year is absent from the sale page). */ protected override async fetchSalePage(ctx: CrawlContext, sale: SaleRef, page: number): Promise<{ url: string; res: ExtractionResult; parsed: ParsedSalePage | null }> { const r = await super.fetchSalePage(ctx, sale, page); if (r.parsed && page === 1 && !sale.date && r.parsed.lots[0]) { const lotUrl = r.parsed.lots[0].url; await this.throttle(lotUrl); const lp = await ctx.fetch(lotUrl, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 }); const date = lp.success && lp.html ? parseLotPageDate(lp.html) : null; if (!date) ctx.anomaly('selector_missing', `${sale.id}: sale date not found on ${lotUrl}`); r.parsed.sale = { ...(r.parsed.sale ?? {}), date: date ?? undefined }; } return r; } } export default function createConnector(meta: ConnectorMeta) { return new MillonConnector(meta); }