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.0 KB · 127 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, 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