SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
6.4 KB · 109 lines typescript
Raw Blame History
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(/&amp;/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