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);
}