TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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, decodeEntities, pick, textOf, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js';56/**7 * PIASA (Paris — design, contemporary art, jewellery & watches, Asian art). Laravel/Livewire site, server-rendered:8 * /fr/ventes-passees lists finished sales of the current year (/fr/auctions/<slug>); every sale page inlines ALL its9 * lots (`forceAllItems`) as product cards (lot number, title, sub-title, "Estimation 6 000 / 8 000 €",10 * "Résultat 23 878 €") and a `wire:snapshot` JSON with start/end dates, place and number. PIASA prints results11 * "Frais de vente inclus" → premium-inclusive.12 */13const BASE = 'https://www.piasa.fr';1415export function parsePastSales(htmlText: string): SaleRef[] {16 const out: SaleRef[] = [];17 const seen = new Set<string>();18 for (const chunk of chunksBetween(htmlText, /<div\s+class="[^"]*\bauction-card\b/)) {19 const href = chunk.match(/href="(\/fr\/auctions\/([^"?#]+))"/);20 if (!href || seen.has(href[2]!)) continue;21 seen.add(href[2]!);22 const title = pick(chunk, /<h3[^>]*>\s*<a[^>]*>([\s\S]*?)<\/a>/) ?? pick(chunk, /<h3[^>]*>([\s\S]*?)<\/h3>/) ?? href[2]!;23 const dateText = pick(chunk, /((?:\d{1,2}\s+)?[A-Za-zéû]{3,9}\.?\s+\d{4})/);24 const online = /online only/i.test(chunk);25 out.push({ id: href[2]!, title, url: `${BASE}${href[1]}`, date: null, location: online ? 'Online' : null, extra: { month_text: dateText, online_only: online } });26 }27 return out;28}2930/** The auction Livewire snapshot (HTML-escaped JSON) carries the dates, place and sale number. */31export function parseAuctionSnapshot(htmlText: string): { startDate: string | null; endDate: string | null; place: string | null; number: string | null; status: string | null; title: string | null } {32 const snaps = [...htmlText.matchAll(/wire:snapshot="([^"]+)"/g)].map((m) => decodeEntities(m[1]!));33 for (const s of snaps) {34 if (!s.includes('"start_date"')) continue;35 const g = (k: string) => s.match(new RegExp(`"${k}":"([^"]*)"`))?.[1] ?? null;36 const num = s.match(/"number":(\d+)/)?.[1] ?? null;37 return { startDate: g('start_date'), endDate: g('end_date'), place: g('place'), number: num, status: g('auction_status'), title: g('title') };38 }39 return { startDate: null, endDate: null, place: null, number: null, status: null, title: null };40}4142function isoFromSnapshot(s: string | null): string | null {43 if (!s) return null;44 const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);45 return m ? `${m[1]}-${m[2]}-${m[3]}T00:00:00.000Z` : (parseEuDate(s)?.toISOString() ?? null);46}4748export function parseSalePage(htmlText: string, sale: SaleRef): ParsedSalePage | null {49 // Online-only sale pages carry the auction snapshot but no inlined product cards (lots are loaded client-side):50 // return an empty lot list so the crawler records `selector_missing` instead of a parse failure.51 const isAuctionPage = /auction-card-container|wire:snapshot="[^"]*start_date/.test(htmlText);52 if (!/\bproduct-card\b/.test(htmlText) && !isAuctionPage) return null;53 const clean = htmlText.replace(/wire:snapshot="[^"]*"/g, '').replace(/<!--\[if [^\]]*\]><!\[endif\]-->/g, '');54 const lots: ParsedLot[] = [];55 for (const chunk of chunksBetween(clean, /<div\s+id="product-\d+"\s+class="[^"]*\bproduct-card\b/)) {56 const productId = chunk.match(/id="product-(\d+)"/)?.[1] ?? null;57 const href = chunk.match(/href="(\/fr\/products\/[^"]+)"/)?.[1] ?? null;58 if (!href) continue;59 const lotNo = pick(chunk, /text-gray-400[^"]*"\s*>\s*([0-9]{1,4}[A-Za-z]?)\s*<\/div>/) ?? href.match(/-(\d+)-\d+-fr$/)?.[1] ?? null;60 const title = pick(chunk, /<h3[^>]*>([\s\S]*?)<\/h3>/);61 if (!lotNo || !title) continue;62 const sub = pick(chunk, /<\/h3>\s*<div class="[^"]*normal-case">([\s\S]*?)<\/div>/);63 const est = chunk.match(/Estimation<\/div>\s*<div[^>]*>([\s\S]*?)<\/div>/)?.[1];64 const res = chunk.match(/Résultat[^<]*<\/div>\s*<div[^>]*>([\s\S]*?)<\/div>/)?.[1];65 const estM = est ? textOf(est).match(/([\d\s.,]+)\s*\/\s*([\d\s.,]+)\s*€/) : null;66 const price = res ? parseEuMoney(textOf(res), 'EUR') : null;67 const image = chunk.match(/<img src="([^"]+)"/)?.[1] ?? null;68 lots.push({69 lotNo,70 title,71 subtitle: sub || null,72 description: null,73 url: absolute(BASE, href)!,74 image,75 price: price?.amount ?? null,76 currency: 'EUR',77 premiumIncluded: true,78 estimateLow: estM ? parseEuMoney(`${estM[1]} €`, 'EUR')?.amount ?? null : null,79 estimateHigh: estM ? parseEuMoney(`${estM[2]} €`, 'EUR')?.amount ?? null : null,80 date: null,81 sold: price !== null,82 extra: { product_id: productId },83 });84 }85 const snap = parseAuctionSnapshot(htmlText);86 const h1 = pick(htmlText, /<h1[^>]*>([\s\S]*?)<\/h1>/);87 const premiumNote = /Frais de vente inclus|frais inclus/i.test(htmlText);88 return {89 lots,90 hasMore: false,91 totalLots: lots.length,92 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 } },93 };94}9596export class PiasaConnector extends SaleResultsConnector {97 readonly version = '1.0.0';98 readonly house: HouseConfig = { houseName: 'PIASA', defaultCurrency: 'EUR', location: 'Paris, France', idKey: 'piasa_lot', premiumIncluded: true, fallbackSlug: 'design_furniture', minIntervalMs: 2500, maxPagesPerSale: 1 };99 protected override minIntervalMs = 2500;100101 async listSales(ctx: CrawlContext): Promise<SaleRef[]> {102 const url = String(this.meta.config.pastSalesUrl ?? `${BASE}/fr/ventes-passees`);103 await this.throttle(url);104 const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 });105 if (!res.success || !res.html) {106 ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`);107 return [];108 }109 // Online-only sales render their lots client-side (no product cards) → put them last so live/catalogue sales110 // with inlined results are processed first; they are still attempted and reported via anomalies.111 const sales = parsePastSales(res.html);112 return [...sales.filter((s) => !s.extra.online_only), ...sales.filter((s) => s.extra.online_only)];113 }114115 salePageUrl(sale: SaleRef): string {116 return sale.url;117 }118119 parseSalePage(res: ExtractionResult, sale: SaleRef): ParsedSalePage | null {120 return res.html ? parseSalePage(res.html, sale) : null;121 }122}123124export default function createConnector(meta: ConnectorMeta) {125 return new PiasaConnector(meta);126}127