TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1/**2 * Parser for the Drouot "site générique" platform used by many French houses (Ader, Osenat, …):3 * - past-sales index: /ventes-passees?year=YYYY (Ader) or /resultats-ventes-passees?year=YYYY (Osenat) with4 * `.calendrier.entry` blocks (title, "jeudi 09 juillet 2026 à 14h00", venue, /catalogue/<id>-slug link);5 * - catalogue: /catalogue/<id>?offset=N&max=50 with `.product` cards (lot number, title, description, image,6 * "Estimation : 150 - 200 EUR", "Résultat : 1 081 EUR") and a page-level `.explicationResultats` note that7 * says whether results are "avec frais" (premium-inclusive) or "sans frais" (hammer) — read per page, never assumed.8 */9import type { CurrencyCode } from '@rareindex/shared';10import { parseEuDate, parseEuMoney } from './index.js';11import { absolute, chunksBetween, pick, textOf, type ParsedLot, type ParsedSalePage, type SaleRef } from './sale-results.js';1213export interface DrouotHouse {14 base: string;15 currency: CurrencyCode;16}1718/** Index page → sales (only entries that link to a /catalogue/ — sales without an online catalogue are skipped). */19export function parseDrouotIndex(htmlText: string, house: DrouotHouse): SaleRef[] {20 const out: SaleRef[] = [];21 const seen = new Set<string>();22 for (const chunk of chunksBetween(htmlText, /<div class="calendrier entry[^"]*"/)) {23 const link = chunk.match(/href="(\/catalogue\/(\d+)[^"]*)"/);24 if (!link) continue;25 const id = link[2]!;26 if (seen.has(id)) continue;27 seen.add(id);28 const title = pick(chunk, /<h2>\s*<a[^>]*>([\s\S]*?)<\/a>/) ?? pick(chunk, /alt="([^"]*)"/) ?? id;29 const dateText = pick(chunk, /bloc_vente_date">[\s\S]*?<\/i>([\s\S]*?)<\/div>/);30 const venue = pick(chunk, /bloc_vente_lieu">[\s\S]*?<\/i>([\s\S]*?)<\/div>/);31 const date = dateText ? parseEuDate(dateText) : null;32 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 } });33 }34 return out;35}3637function cleanVenue(v: string): string {38 return v.replace(/\s*,\s*/g, ', ').replace(/(www\.[^\s,]+)(?:, \1)+/g, '$1').trim();39}4041/** Catalogue page → lots + sale header (title/date/venue) + premium basis. */42export function parseDrouotCatalogue(htmlText: string, sale: SaleRef, house: DrouotHouse, pageSize = 50): ParsedSalePage | null {43 if (!/class="[^"]*\bproduct\b[^"]*"/.test(htmlText) && !/nbre_lot_haut/.test(htmlText)) return null;44 const header = {45 title: pick(htmlText, /<h1 class="nom_vente">([\s\S]*?)<\/h1>/),46 dateText: pick(htmlText, /<div class="date_vente">([\s\S]*?)<\/div>/),47 venue: pick(htmlText, /<div class="lieu_vente">([\s\S]*?)<\/div>/),48 };49 const countText = pick(htmlText, /nbre_lot_haut">([\s\S]*?)<\/div>/);50 const m = countText?.match(/Lots?\s+(\d+)\s+à\s+(\d+)\s+sur\s+(\d+)/i);51 const total = m ? Number(m[3]) : null;52 const to = m ? Number(m[2]) : null;53 const pageNote = pick(htmlText, /explicationResultats">([\s\S]*?)<\/div>/);54 const pagePremium = premiumFromNote(pageNote);55 const lots: ParsedLot[] = [];56 for (const chunk of chunksBetween(htmlText, /<div class="ordre_[a-z]+ product[^"]*"/, /<div class="pagination_catalogue">|<footer|id="footer"/)) {57 const lotId = chunk.match(/lotId(\d+)/)?.[1] ?? null;58 const href = chunk.match(/href="(\/lot\/\d+\/[^"]+)"/)?.[1] ?? null;59 const lotNo = pick(chunk, /<span class='lotnum'>([^<]*)</) ?? pick(chunk, /class="lotnum">([^<]*)</);60 const title = pick(chunk, /<div class="product-title">[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/);61 const desc = pick(chunk, /<h2 id="lotDesc-\d+">([\s\S]*?)<\/h2>/);62 if (!lotNo || !(title || desc)) continue;63 const image = chunk.match(/<img src="([^"]+)" class="lot_visu"/)?.[1] ?? chunk.match(/<img src="(https:\/\/cdn\.drouot\.com[^"]+)"/)?.[1] ?? null;64 const est = pick(chunk, /estimAff4">([\s\S]*?)<\/div>/);65 const estM = est?.match(/([\d\s.,]+)\s*-\s*([\d\s.,]+)\s*([A-Z]{3})?/);66 const resText = pick(chunk, /sale-flash2?">\s*Résultat\s*:?\s*<nobr>([\s\S]*?)<\/nobr>/) ?? pick(chunk, /Résultat\s*:?\s*<nobr>([^<]*)</);67 const price = resText ? parseEuMoney(resText, house.currency) : null;68 const note = pick(chunk, /explicationResultats">([\s\S]*?)<\/div>/);69 const premiumIncluded = premiumFromNote(note) ?? pagePremium;70 // the card title is truncated ("Georges HUGNET (1906-1974)..."); the lotDesc heading carries the full first line71 // also when the short card title is just the artist/maker and the lotDesc heading expands on it72 const truncated = !title || /\.{3}$|…$/.test(title) || (!!desc && desc.length > title.length && desc.toLowerCase().startsWith(title.toLowerCase()));73 const fullTitle = truncated && desc ? desc : (title ?? desc)!.replace(/\.{3}$|…$/, '').trim();74 lots.push({75 lotNo: lotNo.replace(/\s+/g, ''),76 title: fullTitle,77 subtitle: null,78 description: desc && desc !== fullTitle ? desc : null,79 url: absolute(house.base, href) ?? `${sale.url}#lot${lotNo}`,80 image: image ? image.replace(/&/g, '&') : null,81 price: price?.amount ?? null,82 currency: price?.currency ?? house.currency,83 premiumIncluded,84 estimateLow: estM ? parseEuMoney(`${estM[1]} ${estM[3] ?? house.currency}`, house.currency)?.amount ?? null : null,85 estimateHigh: estM ? parseEuMoney(`${estM[2]} ${estM[3] ?? house.currency}`, house.currency)?.amount ?? null : null,86 date: null,87 sold: price !== null,88 extra: { drouot_lot_id: lotId, premium_note: note ?? pageNote ?? null },89 });90 }91 const date = header.dateText ? parseEuDate(header.dateText) : null;92 return {93 lots,94 hasMore: total !== null && to !== null ? to < total : lots.length >= pageSize,95 totalLots: total,96 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 } },97 };98}99100/** "Résultats avec frais" → true; "Résultats sans frais" → false; anything else → null (unknown). */101export function premiumFromNote(note: string | null | undefined): boolean | null {102 if (!note) return null;103 if (/avec\s+frais|frais\s+inclus|frais\s+compris|incl\w*\s+(?:buyer|premium|fees)/i.test(note)) return true;104 if (/sans\s+frais|hors\s+frais|prix\s+marteau|hammer/i.test(note)) return false;105 return null;106}107108export { textOf };109