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.2 KB · 133 lines typescript
Raw Blame History
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(/&amp;/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(/&amp;/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