import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors'; import type { ExtractionResult } from '@rareindex/shared'; import { isSupportedCurrency, stripHtml } from '../_g8-auctions-eu-apac-lib/index.js'; import { SaleResultsConnector, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js'; /** * Bernaerts Auctioneers (Antwerp) — AuctionMobility front-end (live.bernaerts.eu). Every page embeds the * platform's JSON in `viewVars = {...}`: /auctions/past lists finished auctions (row_id, title, dates, * lot_count, currency_code, _detail_url); /auctions//?page=N embeds 36 lots per page * (lot_number, title, sold_price, estimate_low/high, status, currency_code, cover_thumbnail, _detail_url, * schema.org Product jsonld). `sold_price` is the hammer (the auction object separately totals * total_hammer_price and total_sold_value; buyer's premium 30 % is stated in the description). */ const BASE = 'https://live.bernaerts.eu'; type AmAuction = { row_id?: string; title?: string; auction_type?: string; time_start?: string | null; time_start_live_auction?: string | null; effective_end_time?: string | null; location_name?: string | null; lot_count?: number; sold_lot_count?: number; currency_code?: string; _detail_url?: string; publication_status?: string; total_hammer_price?: string | null; total_sold_value?: string | null; default_buyers_premium?: string | number | null }; type AmLot = { row_id?: string; lot_number?: number | string; lot_number_extension?: string | null; title?: string; truncated_description?: string | null; artist?: string | null; sold_price?: string | number | null; estimate_low?: string | number | null; estimate_high?: string | number | null; currency_code?: string | null; status?: string | null; cover_thumbnail?: string | null; _detail_url?: string; extended_end_time?: string | null; auction?: { row_id?: string; effective_end_time?: string | null; currency_code?: string } | null; is_mixed_lot?: boolean | null; quantity?: number | null; when_produced?: string | null; condition?: string | null; dimensions?: string | null }; type QueryInfo = { page_size?: number; page_start_offset?: number; total_num_results?: number; next_page?: string | null }; /** Extract the `viewVars = {...};` JSON literal from an AuctionMobility page. */ export function parseViewVars(htmlText: string): Record | null { const i = htmlText.indexOf('viewVars = '); if (i < 0) return null; const start = htmlText.indexOf('{', i); const end = htmlText.indexOf('', start); if (start < 0 || end < 0) return null; const literal = htmlText.slice(start, end).trim().replace(/;\s*$/, ''); try { return JSON.parse(literal) as Record; } catch { return null; } } function money(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 parsePastAuctions(htmlText: string): SaleRef[] { const vv = parseViewVars(htmlText); const page = (vv?.auctions as { result_page?: AmAuction[] } | undefined)?.result_page; if (!Array.isArray(page)) return []; return page .filter((a) => a.row_id && a._detail_url && a.publication_status !== 'hidden') .map((a) => ({ id: a.row_id!, title: String(a.title ?? a.row_id).trim(), url: `${BASE}${a._detail_url}`, date: a.time_start_live_auction ?? a.effective_end_time ?? a.time_start ?? null, location: a.location_name ?? null, extra: { auction_type: a.auction_type ?? null, lot_count: a.lot_count ?? null, sold_lot_count: a.sold_lot_count ?? null, currency_code: a.currency_code ?? null, ends_at: a.effective_end_time ?? null, total_hammer_price: money(a.total_hammer_price), total_sold_value: money(a.total_sold_value) }, })); } export function parseLotsPage(htmlText: string, sale: SaleRef): ParsedSalePage | null { const vv = parseViewVars(htmlText); const lotsBlock = vv?.lots as { result_page?: AmLot[]; query_info?: QueryInfo } | undefined; if (!lotsBlock || !Array.isArray(lotsBlock.result_page)) return null; const qi = lotsBlock.query_info ?? {}; const lots: ParsedLot[] = []; for (const l of lotsBlock.result_page) { const lotNo = `${l.lot_number ?? ''}${l.lot_number_extension ?? ''}`.trim(); const title = stripHtml(l.title ?? '', 300); if (!lotNo || !title) continue; const price = money(l.sold_price); const cur = (l.currency_code ?? l.auction?.currency_code ?? String(sale.extra.currency_code ?? '') ?? '').toUpperCase(); lots.push({ lotNo, title, subtitle: l.artist ? stripHtml(l.artist, 120) : null, description: stripHtml(l.truncated_description ?? null, 500), url: l._detail_url ? `${BASE}${l._detail_url}` : sale.url, image: l.cover_thumbnail ?? null, price, currency: isSupportedCurrency(cur) ? cur : null, premiumIncluded: false, estimateLow: money(l.estimate_low), estimateHigh: money(l.estimate_high), date: l.extended_end_time ?? l.auction?.effective_end_time ?? null, sold: l.status === 'sold' && price !== null, extra: { lot_row_id: l.row_id ?? null, status: l.status ?? null, is_mixed_lot: l.is_mixed_lot ?? null, quantity: l.quantity ?? null, when_produced: l.when_produced ?? null, condition: l.condition ?? null, dimensions: l.dimensions ?? null }, }); } const total = typeof qi.total_num_results === 'number' ? qi.total_num_results : null; const offset = typeof qi.page_start_offset === 'number' ? qi.page_start_offset : 0; const size = typeof qi.page_size === 'number' ? qi.page_size : lots.length; const auction = vv?.auction as AmAuction | undefined; return { lots, hasMore: total !== null ? offset + size < total : Boolean(qi.next_page), totalLots: total, sale: auction ? { title: auction.title ?? undefined, date: auction.time_start_live_auction ?? auction.effective_end_time ?? undefined, location: auction.location_name ?? undefined, extra: { total_hammer_price: money(auction.total_hammer_price), total_sold_value: money(auction.total_sold_value), auction_type: auction.auction_type ?? null } } : undefined, }; } export class BernaertsConnector extends SaleResultsConnector { readonly version = '1.0.0'; readonly house: HouseConfig = { houseName: 'Bernaerts', defaultCurrency: 'EUR', location: 'Antwerp, Belgium', idKey: 'bernaerts_lot', premiumIncluded: false, fallbackSlug: 'antiques', minIntervalMs: 2000, maxPagesPerSale: 30 }; protected override minIntervalMs = 2000; async listSales(ctx: CrawlContext): Promise { const url = String(this.meta.config.pastUrl ?? `${BASE}/auctions/past`); 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 parsePastAuctions(res.html); } salePageUrl(sale: SaleRef, page: number): string { return page > 1 ? `${sale.url}?page=${page}` : sale.url; } parseSalePage(res: ExtractionResult, sale: SaleRef): ParsedSalePage | null { return res.html ? parseLotsPage(res.html, sale) : null; } } export default function createConnector(meta: ConnectorMeta) { return new BernaertsConnector(meta); }