import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors'; import type { ExtractionResult } from '@rareindex/shared'; import { parseEuDate, parseEuMoney } from '../_g8-auctions-eu-apac-lib/index.js'; import { SaleResultsConnector, absolute, chunksBetween, decodeEntities, pick, textOf, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js'; /** * PIASA (Paris — design, contemporary art, jewellery & watches, Asian art). Laravel/Livewire site, server-rendered: * /fr/ventes-passees lists finished sales of the current year (/fr/auctions/); every sale page inlines ALL its * lots (`forceAllItems`) as product cards (lot number, title, sub-title, "Estimation 6 000 / 8 000 €", * "Résultat 23 878 €") and a `wire:snapshot` JSON with start/end dates, place and number. PIASA prints results * "Frais de vente inclus" → premium-inclusive. */ const BASE = 'https://www.piasa.fr'; export function parsePastSales(htmlText: string): SaleRef[] { const out: SaleRef[] = []; const seen = new Set(); for (const chunk of chunksBetween(htmlText, /]*>\s*]*>([\s\S]*?)<\/a>/) ?? pick(chunk, /]*>([\s\S]*?)<\/h3>/) ?? href[2]!; const dateText = pick(chunk, /((?:\d{1,2}\s+)?[A-Za-zéû]{3,9}\.?\s+\d{4})/); const online = /online only/i.test(chunk); out.push({ id: href[2]!, title, url: `${BASE}${href[1]}`, date: null, location: online ? 'Online' : null, extra: { month_text: dateText, online_only: online } }); } return out; } /** The auction Livewire snapshot (HTML-escaped JSON) carries the dates, place and sale number. */ export function parseAuctionSnapshot(htmlText: string): { startDate: string | null; endDate: string | null; place: string | null; number: string | null; status: string | null; title: string | null } { const snaps = [...htmlText.matchAll(/wire:snapshot="([^"]+)"/g)].map((m) => decodeEntities(m[1]!)); for (const s of snaps) { if (!s.includes('"start_date"')) continue; const g = (k: string) => s.match(new RegExp(`"${k}":"([^"]*)"`))?.[1] ?? null; const num = s.match(/"number":(\d+)/)?.[1] ?? null; return { startDate: g('start_date'), endDate: g('end_date'), place: g('place'), number: num, status: g('auction_status'), title: g('title') }; } return { startDate: null, endDate: null, place: null, number: null, status: null, title: null }; } function isoFromSnapshot(s: string | null): string | null { if (!s) return null; const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? `${m[1]}-${m[2]}-${m[3]}T00:00:00.000Z` : (parseEuDate(s)?.toISOString() ?? null); } export function parseSalePage(htmlText: string, sale: SaleRef): ParsedSalePage | null { // Online-only sale pages carry the auction snapshot but no inlined product cards (lots are loaded client-side): // return an empty lot list so the crawler records `selector_missing` instead of a parse failure. const isAuctionPage = /auction-card-container|wire:snapshot="[^"]*start_date/.test(htmlText); if (!/\bproduct-card\b/.test(htmlText) && !isAuctionPage) return null; const clean = htmlText.replace(/wire:snapshot="[^"]*"/g, '').replace(//g, ''); const lots: ParsedLot[] = []; for (const chunk of chunksBetween(clean, /\s*([0-9]{1,4}[A-Za-z]?)\s*<\/div>/) ?? href.match(/-(\d+)-\d+-fr$/)?.[1] ?? null; const title = pick(chunk, /]*>([\s\S]*?)<\/h3>/); if (!lotNo || !title) continue; const sub = pick(chunk, /<\/h3>\s*
([\s\S]*?)<\/div>/); const est = chunk.match(/Estimation<\/div>\s*]*>([\s\S]*?)<\/div>/)?.[1]; const res = chunk.match(/Résultat[^<]*<\/div>\s*]*>([\s\S]*?)<\/div>/)?.[1]; const estM = est ? textOf(est).match(/([\d\s.,]+)\s*\/\s*([\d\s.,]+)\s*€/) : null; const price = res ? parseEuMoney(textOf(res), 'EUR') : null; const image = chunk.match(/]*>([\s\S]*?)<\/h1>/); const premiumNote = /Frais de vente inclus|frais inclus/i.test(htmlText); return { lots, hasMore: false, totalLots: lots.length, sale: { title: snap.title ?? h1 ?? undefined, date: isoFromSnapshot(snap.endDate ?? snap.startDate) ?? undefined, location: snap.place ? (snap.place === 'Online' ? 'Online' : `${snap.place}, Paris, France`) : undefined, extra: { sale_number: snap.number, start_date: snap.startDate, end_date: snap.endDate, auction_status: snap.status, premium_note: premiumNote ? 'Frais de vente inclus' : null } }, }; } export class PiasaConnector extends SaleResultsConnector { readonly version = '1.0.0'; readonly house: HouseConfig = { houseName: 'PIASA', defaultCurrency: 'EUR', location: 'Paris, France', idKey: 'piasa_lot', premiumIncluded: true, fallbackSlug: 'design_furniture', minIntervalMs: 2500, maxPagesPerSale: 1 }; protected override minIntervalMs = 2500; async listSales(ctx: CrawlContext): Promise { const url = String(this.meta.config.pastSalesUrl ?? `${BASE}/fr/ventes-passees`); await this.throttle(url); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 }); if (!res.success || !res.html) { ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`); return []; } // Online-only sales render their lots client-side (no product cards) → put them last so live/catalogue sales // with inlined results are processed first; they are still attempted and reported via anomalies. const sales = parsePastSales(res.html); return [...sales.filter((s) => !s.extra.online_only), ...sales.filter((s) => s.extra.online_only)]; } salePageUrl(sale: SaleRef): string { return sale.url; } parseSalePage(res: ExtractionResult, sale: SaleRef): ParsedSalePage | null { return res.html ? parseSalePage(res.html, sale) : null; } } export default function createConnector(meta: ConnectorMeta) { return new PiasaConnector(meta); }