TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { html as H, type ConnectorMeta, type CrawlContext } from '@rareindex/connectors';2import type { ExtractionResult } from '@rareindex/shared';3import { stripHtml } from '../_g8-auctions-eu-apac-lib/index.js';4import { SaleResultsConnector, resolveCategory, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js';56/**7 * Aguttes (Neuilly-sur-Seine / Paris) — Next.js site on the Artisio auction platform. Every catalogue page8 * (/catalogue/<id-or-slug>?page=N) embeds the lot list and the auction object in __NEXT_DATA__ with9 * numeric `hammer_price` (hammer, EUR), estimates, status (sold/unsold), images and bilingual titles.10 * Past sales are enumerated from the public sitemap (sitemap-fr-ventes-passees.xml).11 */12const BASE = 'https://www.aguttes.com';1314type NextLot = { uuid?: string; lot_no?: string; status?: string; low?: string | number | null; high?: string | number | null; hammer_price?: string | number | null; title?: Record<string, string> | string; quantity?: number; end_date?: string | null; num_of_bids?: number; primary_image?: { data?: Record<string, { url?: string }> } | null; dynamic_fields?: Record<string, Record<string, unknown>> };15type NextAuction = { uuid?: string; sale_no?: string; title?: Record<string, string>; 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 };16type PageProps = { auction?: NextAuction; auctionLots?: { count?: number; limit?: number; page?: number; results?: NextLot[] }; drouotVente?: unknown };1718function num(v: unknown): number | null {19 if (v === null || v === undefined || v === '') return null;20 const n = Number(v);21 return Number.isFinite(n) && n > 0 ? n : null;22}2324export function humanizeSlug(slug: string): string {25 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();26}2728/** Sitemap of past sales → SaleRef list (newest last in the sitemap → we reverse). */29export function parseSalesSitemap(xml: string): SaleRef[] {30 const locs = [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]!.replace(/&/g, '&'));31 const out: SaleRef[] = [];32 const seen = new Set<string>();33 for (const url of locs) {34 const m = url.match(/\/catalogue\/([^/?#]+)$/);35 if (!m || seen.has(m[1]!)) continue;36 seen.add(m[1]!);37 out.push({ id: m[1]!, title: humanizeSlug(m[1]!), url, date: null, location: null, extra: {} });38 }39 return out.reverse();40}4142/** Parse a catalogue page: lots + auction facts from __NEXT_DATA__; lot URLs from the rendered anchors. */43export function parseCataloguePage(htmlText: string, sale: SaleRef, page: number): ParsedSalePage | null {44 const data = H.nextData(htmlText) as { props?: { pageProps?: PageProps } } | null;45 const pp = data?.props?.pageProps;46 if (!pp?.auctionLots || !Array.isArray(pp.auctionLots.results)) return null;47 const auction = pp.auction ?? {};48 const currency = auction.currency?.code ?? 'EUR';49 const hrefs = [...htmlText.matchAll(/href="(\/lot\/[^"]+)"/g)].map((m) => m[1]!.replace(/&/g, '&'));50 const lots: ParsedLot[] = [];51 for (const l of pp.auctionLots.results) {52 const lotNo = String(l.lot_no ?? '').trim();53 const en = (l.dynamic_fields?.en ?? {}) as Record<string, unknown>;54 const fr = (l.dynamic_fields?.fr ?? {}) as Record<string, unknown>;55 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)) ?? '';56 const title = stripHtml(rawTitle, 300) ?? '';57 if (!lotNo || !title) continue;58 const uuid = String(l.uuid ?? '');59 const href = hrefs.find((h) => uuid && h.endsWith(uuid)) ?? (typeof en.url_legacy === 'string' ? en.url_legacy : null);60 const url = href ? `${BASE}${href}` : `${sale.url}?page=${page}#lot-${lotNo}`;61 const img = l.primary_image?.data;62 const image = img?.lg?.url ?? img?.xlg?.url ?? img?.sm?.url ?? null;63 const description = stripHtml((en.description as string | undefined) ?? (fr.description as string | undefined) ?? null, 500);64 const hammer = num(l.hammer_price);65 const sold = l.status === 'sold' && hammer !== null;66 lots.push({67 lotNo,68 title: String(title).trim(),69 subtitle: typeof en.artist === 'string' && en.artist.trim() && en.artist.trim() !== String(title).trim() ? en.artist.trim() : null,70 description,71 url,72 image,73 price: hammer,74 currency,75 premiumIncluded: false,76 estimateLow: num(l.low),77 estimateHigh: num(l.high),78 date: l.end_date ?? null,79 sold,80 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 },81 });82 }83 const count = Number(pp.auctionLots.count ?? lots.length);84 const limit = Number(pp.auctionLots.limit ?? 24);85 const cur = Number(pp.auctionLots.page ?? page);86 const title = auction.title?.en || auction.title?.fr || sale.title;87 return {88 lots,89 hasMore: cur * limit < count,90 totalLots: Number.isFinite(count) ? count : null,91 sale: {92 title,93 date: auction.start_date ?? null,94 location: auction.branch?.city ? `${auction.branch.city}, France` : null,95 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 },96 },97 };98}99100export class AguttesConnector extends SaleResultsConnector {101 readonly version = '1.0.0';102 readonly house: HouseConfig = { houseName: 'Aguttes', defaultCurrency: 'EUR', location: 'Neuilly-sur-Seine, France', idKey: 'aguttes_lot', premiumIncluded: false, fallbackSlug: 'antiques', minIntervalMs: 2000 };103 protected override minIntervalMs = 2000;104105 async listSales(ctx: CrawlContext): Promise<SaleRef[]> {106 const url = String(this.meta.config.pastSalesSitemap ?? `${BASE}/sitemap-fr-ventes-passees.xml`);107 await this.throttle(url);108 const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 });109 if (!res.success || !res.html) {110 ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`);111 return [];112 }113 return parseSalesSitemap(res.html);114 }115116 salePageUrl(sale: SaleRef, page: number): string {117 return page > 1 ? `${sale.url}?page=${page}` : sale.url;118 }119120 parseSalePage(res: ExtractionResult, sale: SaleRef, page: number): ParsedSalePage | null {121 return res.html ? parseCataloguePage(res.html, sale, page) : null;122 }123124 override categoryFor(sale: SaleRef, lot: ParsedLot): string | null {125 const brand = String(lot.extra.car_brand ?? '');126 return resolveCategory(`${sale.title} ${String(sale.extra.title_fr ?? '')}`, brand ? { ...lot, subtitle: `${lot.subtitle ?? ''} ${brand}`.trim() } : lot, this.house.fallbackSlug);127 }128}129130export default function createConnector(meta: ConnectorMeta) {131 return new AguttesConnector(meta);132}133