TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors';2import type { ExtractionResult } from '@rareindex/shared';3import { parseEuDate, parseEuMoney } from '../_g8-auctions-eu-apac-lib/index.js';4import { SaleResultsConnector, absolute, chunksBetween, pick, textOf, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js';56/**7 * Millon (Paris, Bruxelles, Nice…) — Drupal site, fully server-rendered. Past sales: /catalogue/ventes-passees?page=N8 * (0-based, 36 cards/page, ~1 740 sales); sale page /catalogue/vente<id>-<slug>?page=N (0-based, 50 lots/page) with9 * cards "Lot 12 · title · sub-title · description · Adjugé à | 160 €". The sale year is not printed on the sale10 * page ("lun 06 Juil - lun 07 Sep"); each lot page states "Vente <title> du 6 juillet 2026", so the connector reads11 * the first lot page of a sale once to get the date. "Adjugé à" is not labelled hammer/with-fees → premium basis null.12 */13const BASE = 'https://www.millon.com';14const PAGE = 50;1516export function parseMillonIndex(htmlText: string): SaleRef[] {17 const out: SaleRef[] = [];18 const seen = new Set<string>();19 for (const chunk of chunksBetween(htmlText, /<article class="card[^"]*">/)) {20 const href = chunk.match(/href="(\/catalogue\/vente(\d+)[^"/?#]*)"/);21 if (!href || seen.has(href[2]!)) continue;22 seen.add(href[2]!);23 const title = pick(chunk, /<h5 class="card-title"><a[^>]*>([\s\S]*?)<\/a>/) ?? pick(chunk, /title="([^"]+)"/) ?? href[2]!;24 const dayText = pick(chunk, /day-of-sale">([\s\S]*?)<\/span>/);25 const location = pick(chunk, /selling-location">([\s\S]*?)<\/span>/);26 out.push({ id: href[2]!, title, url: `${BASE}${href[1]}`, date: null, location: location || null, extra: { day_text: dayText } });27 }28 return out;29}3031/** Total pages from the Drupal pagerer widget (`max="49"` → 49 pages, 0-based query). */32export function parsePagerMax(htmlText: string): number | null {33 const m = htmlText.match(/class="pagerer-page"[^>]*max="(\d+)"/);34 return m ? Number(m[1]) : null;35}3637/** Lot page → sale date ("Vente <a>…</a> du 6 juillet 2026"). */38export function parseLotPageDate(htmlText: string): string | null {39 const m = htmlText.match(/sale--info">[\s\S]*?<\/a>\s*(?:du|le|des)\s+([^<]+?)\s*<\/p>/);40 const d = m ? parseEuDate(m[1]!) : null;41 return d ? d.toISOString() : null;42}4344export function parseMillonSalePage(htmlText: string, sale: SaleRef, page: number): ParsedSalePage | null {45 if (!/class="card[^"]*"/.test(htmlText) || !/<span class="lot">/.test(htmlText)) return null;46 const lots: ParsedLot[] = [];47 for (const chunk of chunksBetween(htmlText, /<article class="card[^"]*">/)) {48 const lotNo = pick(chunk, /<span class="lot">\s*Lot\s*([^<]*)<\/span>/);49 const href = chunk.match(/href="(\/catalogue\/vente\d+[^"]*\/lot\d+[^"]*)"/)?.[1] ?? null;50 const title = pick(chunk, /<h5 class="card-title"><a[^>]*>([\s\S]*?)<\/a>/);51 if (!lotNo || !title || !href) continue;52 const sub = pick(chunk, /<p class="sub-title">([\s\S]*?)<\/p>/);53 const desc = pick(chunk, /<div class="description">([\s\S]*?)<\/div>/);54 const est = chunk.match(/<span class="label">\s*Estimation[^<]*<\/span>\s*([^<]+)</);55 const adj = chunk.match(/<span class="label">\s*Adjugé[^<]*<\/span>\s*([^<]+)</);56 const price = adj ? parseEuMoney(textOf(adj[1]), 'EUR') : null;57 const estM = est ? textOf(est[1]).match(/([\d\s.,]+)\s*€?\s*-\s*([\d\s.,]+)\s*€/) : null;58 const image = chunk.match(/srcset="(\/sites\/millon\/files\/[^"\s]+)/)?.[1] ?? null;59 lots.push({60 lotNo: lotNo.replace(/\s+/g, ''),61 title,62 subtitle: sub || null,63 description: desc || null,64 url: absolute(BASE, href)!,65 image: image ? absolute(BASE, image) : null,66 price: price?.amount ?? null,67 currency: 'EUR',68 premiumIncluded: null,69 estimateLow: estM ? parseEuMoney(`${estM[1]} €`, 'EUR')?.amount ?? null : null,70 estimateHigh: estM ? parseEuMoney(`${estM[2]} €`, 'EUR')?.amount ?? null : null,71 date: null,72 sold: price !== null,73 extra: { adjuge_text: adj ? textOf(adj[1]) : null },74 });75 }76 const max = parsePagerMax(htmlText);77 const title = pick(htmlText, /<h1 class="title-large">([\s\S]*?)<\/h1>/);78 const type = pick(htmlText, /<p class="type">([\s\S]*?)<\/p>/);79 const location = pick(htmlText, /<span class="sale-status">([\s\S]*?)<\/span>/) ?? pick(htmlText, /selling-location">([\s\S]*?)<\/span>/);80 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>/) } } };81}8283export class MillonConnector extends SaleResultsConnector {84 readonly version = '1.0.0';85 readonly house: HouseConfig = { houseName: 'Millon', defaultCurrency: 'EUR', location: 'Paris, France', idKey: 'millon_lot', premiumIncluded: null, fallbackSlug: 'antiques', minIntervalMs: 2500, maxPagesPerSale: 30 };86 protected override minIntervalMs = 2500;8788 async listSales(ctx: CrawlContext): Promise<SaleRef[]> {89 const pages = ctx.options.mode === 'backfill' ? Number(this.meta.config.backfillIndexPages ?? 60) : Number(this.meta.config.indexPages ?? 1);90 const all: SaleRef[] = [];91 for (let p = 0; p < pages; p++) {92 const url = `${BASE}/catalogue/ventes-passees${p > 0 ? `?page=${p}` : ''}`;93 await this.throttle(url);94 const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 });95 if (!res.success || !res.html) {96 ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`);97 break;98 }99 const sales = parseMillonIndex(res.html);100 if (sales.length === 0) break;101 // keep index order (most recent first) as a pseudo-date so incremental runs pick newest sales first102 for (const s of sales) all.push({ ...s, extra: { ...s.extra, index_rank: all.length } });103 const max = parsePagerMax(res.html);104 if (max !== null && p + 1 >= max) break;105 }106 return all;107 }108109 salePageUrl(sale: SaleRef, page: number): string {110 return page > 1 ? `${sale.url}?page=${page - 1}` : sale.url;111 }112113 parseSalePage(res: ExtractionResult, sale: SaleRef, page: number): ParsedSalePage | null {114 return res.html ? parseMillonSalePage(res.html, sale, page) : null;115 }116117 /** Page 1 also fetches the first lot page to learn the sale date (year is absent from the sale page). */118 protected override async fetchSalePage(ctx: CrawlContext, sale: SaleRef, page: number): Promise<{ url: string; res: ExtractionResult; parsed: ParsedSalePage | null }> {119 const r = await super.fetchSalePage(ctx, sale, page);120 if (r.parsed && page === 1 && !sale.date && r.parsed.lots[0]) {121 const lotUrl = r.parsed.lots[0].url;122 await this.throttle(lotUrl);123 const lp = await ctx.fetch(lotUrl, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 });124 const date = lp.success && lp.html ? parseLotPageDate(lp.html) : null;125 if (!date) ctx.anomaly('selector_missing', `${sale.id}: sale date not found on ${lotUrl}`);126 r.parsed.sale = { ...(r.parsed.sale ?? {}), date: date ?? undefined };127 }128 return r;129 }130}131132export default function createConnector(meta: ConnectorMeta) {133 return new MillonConnector(meta);134}135