import { html as H, type ConnectorMeta, type CrawlContext } from '@rareindex/connectors'; import type { ExtractionResult } from '@rareindex/shared'; import { stripHtml } from '../_g8-auctions-eu-apac-lib/index.js'; import { SaleResultsConnector, resolveCategory, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js'; /** * Aguttes (Neuilly-sur-Seine / Paris) — Next.js site on the Artisio auction platform. Every catalogue page * (/catalogue/?page=N) embeds the lot list and the auction object in __NEXT_DATA__ with * numeric `hammer_price` (hammer, EUR), estimates, status (sold/unsold), images and bilingual titles. * Past sales are enumerated from the public sitemap (sitemap-fr-ventes-passees.xml). */ const BASE = 'https://www.aguttes.com'; type NextLot = { uuid?: string; lot_no?: string; status?: string; low?: string | number | null; high?: string | number | null; hammer_price?: string | number | null; title?: Record | string; quantity?: number; end_date?: string | null; num_of_bids?: number; primary_image?: { data?: Record } | null; dynamic_fields?: Record> }; type NextAuction = { uuid?: string; sale_no?: string; title?: Record; start_date?: string | null; end_date?: string | null; status?: string; type?: string; currency?: { code?: string }; premiums?: Array<{ percent?: number; amount_over?: number }>; branch?: { name?: string; city?: string; country_code?: string }; department_uuid?: string | null }; type PageProps = { auction?: NextAuction; auctionLots?: { count?: number; limit?: number; page?: number; results?: NextLot[] }; drouotVente?: unknown }; function num(v: unknown): number | null { if (v === null || v === undefined || v === '') return null; const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; } export function humanizeSlug(slug: string): string { return slug.replace(/-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, '').replace(/-/g, ' ').replace(/\s+/g, ' ').trim(); } /** Sitemap of past sales → SaleRef list (newest last in the sitemap → we reverse). */ export function parseSalesSitemap(xml: string): SaleRef[] { const locs = [...xml.matchAll(/\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]!.replace(/&/g, '&')); const out: SaleRef[] = []; const seen = new Set(); for (const url of locs) { const m = url.match(/\/catalogue\/([^/?#]+)$/); if (!m || seen.has(m[1]!)) continue; seen.add(m[1]!); out.push({ id: m[1]!, title: humanizeSlug(m[1]!), url, date: null, location: null, extra: {} }); } return out.reverse(); } /** Parse a catalogue page: lots + auction facts from __NEXT_DATA__; lot URLs from the rendered anchors. */ export function parseCataloguePage(htmlText: string, sale: SaleRef, page: number): ParsedSalePage | null { const data = H.nextData(htmlText) as { props?: { pageProps?: PageProps } } | null; const pp = data?.props?.pageProps; if (!pp?.auctionLots || !Array.isArray(pp.auctionLots.results)) return null; const auction = pp.auction ?? {}; const currency = auction.currency?.code ?? 'EUR'; const hrefs = [...htmlText.matchAll(/href="(\/lot\/[^"]+)"/g)].map((m) => m[1]!.replace(/&/g, '&')); const lots: ParsedLot[] = []; for (const l of pp.auctionLots.results) { const lotNo = String(l.lot_no ?? '').trim(); const en = (l.dynamic_fields?.en ?? {}) as Record; const fr = (l.dynamic_fields?.fr ?? {}) as Record; const rawTitle = (typeof l.title === 'object' && l.title ? (l.title.en || l.title.fr) : typeof l.title === 'string' ? l.title : (en.title as string | undefined) || (fr.title as string | undefined)) ?? ''; const title = stripHtml(rawTitle, 300) ?? ''; if (!lotNo || !title) continue; const uuid = String(l.uuid ?? ''); const href = hrefs.find((h) => uuid && h.endsWith(uuid)) ?? (typeof en.url_legacy === 'string' ? en.url_legacy : null); const url = href ? `${BASE}${href}` : `${sale.url}?page=${page}#lot-${lotNo}`; const img = l.primary_image?.data; const image = img?.lg?.url ?? img?.xlg?.url ?? img?.sm?.url ?? null; const description = stripHtml((en.description as string | undefined) ?? (fr.description as string | undefined) ?? null, 500); const hammer = num(l.hammer_price); const sold = l.status === 'sold' && hammer !== null; lots.push({ lotNo, title: String(title).trim(), subtitle: typeof en.artist === 'string' && en.artist.trim() && en.artist.trim() !== String(title).trim() ? en.artist.trim() : null, description, url, image, price: hammer, currency, premiumIncluded: false, estimateLow: num(l.low), estimateHigh: num(l.high), date: l.end_date ?? null, sold, extra: { status: l.status ?? null, lot_uuid: uuid || null, num_of_bids: l.num_of_bids ?? null, car_brand: (en.car_brand as string | undefined) || null, quantity: l.quantity ?? null }, }); } const count = Number(pp.auctionLots.count ?? lots.length); const limit = Number(pp.auctionLots.limit ?? 24); const cur = Number(pp.auctionLots.page ?? page); const title = auction.title?.en || auction.title?.fr || sale.title; return { lots, hasMore: cur * limit < count, totalLots: Number.isFinite(count) ? count : null, sale: { title, date: auction.start_date ?? null, location: auction.branch?.city ? `${auction.branch.city}, France` : null, extra: { sale_no: auction.sale_no ?? null, auction_uuid: auction.uuid ?? null, auction_status: auction.status ?? null, auction_type: auction.type ?? null, premiums: auction.premiums ?? null, title_fr: auction.title?.fr ?? null }, }, }; } export class AguttesConnector extends SaleResultsConnector { readonly version = '1.0.0'; readonly house: HouseConfig = { houseName: 'Aguttes', defaultCurrency: 'EUR', location: 'Neuilly-sur-Seine, France', idKey: 'aguttes_lot', premiumIncluded: false, fallbackSlug: 'antiques', minIntervalMs: 2000 }; protected override minIntervalMs = 2000; async listSales(ctx: CrawlContext): Promise { const url = String(this.meta.config.pastSalesSitemap ?? `${BASE}/sitemap-fr-ventes-passees.xml`); 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 []; } return parseSalesSitemap(res.html); } salePageUrl(sale: SaleRef, page: number): string { return page > 1 ? `${sale.url}?page=${page}` : sale.url; } parseSalePage(res: ExtractionResult, sale: SaleRef, page: number): ParsedSalePage | null { return res.html ? parseCataloguePage(res.html, sale, page) : null; } override categoryFor(sale: SaleRef, lot: ParsedLot): string | null { const brand = String(lot.extra.car_brand ?? ''); return resolveCategory(`${sale.title} ${String(sale.extra.title_fr ?? '')}`, brand ? { ...lot, subtitle: `${lot.subtitle ?? ''} ${brand}`.trim() } : lot, this.house.fallbackSlug); } } export default function createConnector(meta: ConnectorMeta) { return new AguttesConnector(meta); }