/** * Parser for the Drouot "site générique" platform used by many French houses (Ader, Osenat, …): * - past-sales index: /ventes-passees?year=YYYY (Ader) or /resultats-ventes-passees?year=YYYY (Osenat) with * `.calendrier.entry` blocks (title, "jeudi 09 juillet 2026 à 14h00", venue, /catalogue/-slug link); * - catalogue: /catalogue/?offset=N&max=50 with `.product` cards (lot number, title, description, image, * "Estimation : 150 - 200 EUR", "Résultat : 1 081 EUR") and a page-level `.explicationResultats` note that * says whether results are "avec frais" (premium-inclusive) or "sans frais" (hammer) — read per page, never assumed. */ import type { CurrencyCode } from '@rareindex/shared'; import { parseEuDate, parseEuMoney } from './index.js'; import { absolute, chunksBetween, pick, textOf, type ParsedLot, type ParsedSalePage, type SaleRef } from './sale-results.js'; export interface DrouotHouse { base: string; currency: CurrencyCode; } /** Index page → sales (only entries that link to a /catalogue/ — sales without an online catalogue are skipped). */ export function parseDrouotIndex(htmlText: string, house: DrouotHouse): SaleRef[] { const out: SaleRef[] = []; const seen = new Set(); for (const chunk of chunksBetween(htmlText, /
\s*]*>([\s\S]*?)<\/a>/) ?? pick(chunk, /alt="([^"]*)"/) ?? id; const dateText = pick(chunk, /bloc_vente_date">[\s\S]*?<\/i>([\s\S]*?)<\/div>/); const venue = pick(chunk, /bloc_vente_lieu">[\s\S]*?<\/i>([\s\S]*?)<\/div>/); const date = dateText ? parseEuDate(dateText) : null; out.push({ id, title, url: `${house.base}${link[1]!.split('?')[0]}`, date: date ? date.toISOString() : null, location: venue ? cleanVenue(venue) : null, extra: { date_text: dateText } }); } return out; } function cleanVenue(v: string): string { return v.replace(/\s*,\s*/g, ', ').replace(/(www\.[^\s,]+)(?:, \1)+/g, '$1').trim(); } /** Catalogue page → lots + sale header (title/date/venue) + premium basis. */ export function parseDrouotCatalogue(htmlText: string, sale: SaleRef, house: DrouotHouse, pageSize = 50): ParsedSalePage | null { if (!/class="[^"]*\bproduct\b[^"]*"/.test(htmlText) && !/nbre_lot_haut/.test(htmlText)) return null; const header = { title: pick(htmlText, /

([\s\S]*?)<\/h1>/), dateText: pick(htmlText, /
([\s\S]*?)<\/div>/), venue: pick(htmlText, /
([\s\S]*?)<\/div>/), }; const countText = pick(htmlText, /nbre_lot_haut">([\s\S]*?)<\/div>/); const m = countText?.match(/Lots?\s+(\d+)\s+à\s+(\d+)\s+sur\s+(\d+)/i); const total = m ? Number(m[3]) : null; const to = m ? Number(m[2]) : null; const pageNote = pick(htmlText, /explicationResultats">([\s\S]*?)<\/div>/); const pagePremium = premiumFromNote(pageNote); const lots: ParsedLot[] = []; for (const chunk of chunksBetween(htmlText, /
|([^<]*)([^<]*)[\s\S]*?]*>([\s\S]*?)<\/a>/); const desc = pick(chunk, /

([\s\S]*?)<\/h2>/); if (!lotNo || !(title || desc)) continue; const image = chunk.match(/([\s\S]*?)<\/div>/); const estM = est?.match(/([\d\s.,]+)\s*-\s*([\d\s.,]+)\s*([A-Z]{3})?/); const resText = pick(chunk, /sale-flash2?">\s*Résultat\s*:?\s*([\s\S]*?)<\/nobr>/) ?? pick(chunk, /Résultat\s*:?\s*([^<]*)([\s\S]*?)<\/div>/); const premiumIncluded = premiumFromNote(note) ?? pagePremium; // the card title is truncated ("Georges HUGNET (1906-1974)..."); the lotDesc heading carries the full first line // also when the short card title is just the artist/maker and the lotDesc heading expands on it const truncated = !title || /\.{3}$|…$/.test(title) || (!!desc && desc.length > title.length && desc.toLowerCase().startsWith(title.toLowerCase())); const fullTitle = truncated && desc ? desc : (title ?? desc)!.replace(/\.{3}$|…$/, '').trim(); lots.push({ lotNo: lotNo.replace(/\s+/g, ''), title: fullTitle, subtitle: null, description: desc && desc !== fullTitle ? desc : null, url: absolute(house.base, href) ?? `${sale.url}#lot${lotNo}`, image: image ? image.replace(/&/g, '&') : null, price: price?.amount ?? null, currency: price?.currency ?? house.currency, premiumIncluded, estimateLow: estM ? parseEuMoney(`${estM[1]} ${estM[3] ?? house.currency}`, house.currency)?.amount ?? null : null, estimateHigh: estM ? parseEuMoney(`${estM[2]} ${estM[3] ?? house.currency}`, house.currency)?.amount ?? null : null, date: null, sold: price !== null, extra: { drouot_lot_id: lotId, premium_note: note ?? pageNote ?? null }, }); } const date = header.dateText ? parseEuDate(header.dateText) : null; return { lots, hasMore: total !== null && to !== null ? to < total : lots.length >= pageSize, totalLots: total, sale: { title: header.title ?? undefined, date: date ? date.toISOString() : undefined, location: header.venue ? cleanVenue(header.venue) : undefined, extra: { premium_note: pageNote ?? null, date_text: header.dateText ?? null } }, }; } /** "Résultats avec frais" → true; "Résultats sans frais" → false; anything else → null (unknown). */ export function premiumFromNote(note: string | null | undefined): boolean | null { if (!note) return null; if (/avec\s+frais|frais\s+inclus|frais\s+compris|incl\w*\s+(?:buyer|premium|fees)/i.test(note)) return true; if (/sans\s+frais|hors\s+frais|prix\s+marteau|hammer/i.test(note)) return false; return null; } export { textOf };