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%
7.3 KB · 135 lines typescript
Raw Blame History
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