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.3 KB · 126 lines typescript
Raw Blame History
1import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors';2import type { ExtractionResult } from '@rareindex/shared';3import { isSupportedCurrency, stripHtml } from '../_g8-auctions-eu-apac-lib/index.js';4import { SaleResultsConnector, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js';56/**7 * Bernaerts Auctioneers (Antwerp) — AuctionMobility front-end (live.bernaerts.eu). Every page embeds the8 * platform's JSON in `viewVars = {...}`: /auctions/past lists finished auctions (row_id, title, dates,9 * lot_count, currency_code, _detail_url); /auctions/<row_id>/<slug>?page=N embeds 36 lots per page10 * (lot_number, title, sold_price, estimate_low/high, status, currency_code, cover_thumbnail, _detail_url,11 * schema.org Product jsonld). `sold_price` is the hammer (the auction object separately totals12 * total_hammer_price and total_sold_value; buyer's premium 30 % is stated in the description).13 */14const BASE = 'https://live.bernaerts.eu';1516type 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 };17type 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 };18type QueryInfo = { page_size?: number; page_start_offset?: number; total_num_results?: number; next_page?: string | null };1920/** Extract the `viewVars = {...};` JSON literal from an AuctionMobility page. */21export function parseViewVars(htmlText: string): Record<string, unknown> | null {22  const i = htmlText.indexOf('viewVars = ');23  if (i < 0) return null;24  const start = htmlText.indexOf('{', i);25  const end = htmlText.indexOf('</script>', start);26  if (start < 0 || end < 0) return null;27  const literal = htmlText.slice(start, end).trim().replace(/;\s*$/, '');28  try {29    return JSON.parse(literal) as Record<string, unknown>;30  } catch {31    return null;32  }33}3435function money(v: unknown): number | null {36  if (v === null || v === undefined || v === '') return null;37  const n = Number(v);38  return Number.isFinite(n) && n > 0 ? n : null;39}4041export function parsePastAuctions(htmlText: string): SaleRef[] {42  const vv = parseViewVars(htmlText);43  const page = (vv?.auctions as { result_page?: AmAuction[] } | undefined)?.result_page;44  if (!Array.isArray(page)) return [];45  return page46    .filter((a) => a.row_id && a._detail_url && a.publication_status !== 'hidden')47    .map((a) => ({48      id: a.row_id!,49      title: String(a.title ?? a.row_id).trim(),50      url: `${BASE}${a._detail_url}`,51      date: a.time_start_live_auction ?? a.effective_end_time ?? a.time_start ?? null,52      location: a.location_name ?? null,53      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) },54    }));55}5657export function parseLotsPage(htmlText: string, sale: SaleRef): ParsedSalePage | null {58  const vv = parseViewVars(htmlText);59  const lotsBlock = vv?.lots as { result_page?: AmLot[]; query_info?: QueryInfo } | undefined;60  if (!lotsBlock || !Array.isArray(lotsBlock.result_page)) return null;61  const qi = lotsBlock.query_info ?? {};62  const lots: ParsedLot[] = [];63  for (const l of lotsBlock.result_page) {64    const lotNo = `${l.lot_number ?? ''}${l.lot_number_extension ?? ''}`.trim();65    const title = stripHtml(l.title ?? '', 300);66    if (!lotNo || !title) continue;67    const price = money(l.sold_price);68    const cur = (l.currency_code ?? l.auction?.currency_code ?? String(sale.extra.currency_code ?? '') ?? '').toUpperCase();69    lots.push({70      lotNo,71      title,72      subtitle: l.artist ? stripHtml(l.artist, 120) : null,73      description: stripHtml(l.truncated_description ?? null, 500),74      url: l._detail_url ? `${BASE}${l._detail_url}` : sale.url,75      image: l.cover_thumbnail ?? null,76      price,77      currency: isSupportedCurrency(cur) ? cur : null,78      premiumIncluded: false,79      estimateLow: money(l.estimate_low),80      estimateHigh: money(l.estimate_high),81      date: l.extended_end_time ?? l.auction?.effective_end_time ?? null,82      sold: l.status === 'sold' && price !== null,83      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 },84    });85  }86  const total = typeof qi.total_num_results === 'number' ? qi.total_num_results : null;87  const offset = typeof qi.page_start_offset === 'number' ? qi.page_start_offset : 0;88  const size = typeof qi.page_size === 'number' ? qi.page_size : lots.length;89  const auction = vv?.auction as AmAuction | undefined;90  return {91    lots,92    hasMore: total !== null ? offset + size < total : Boolean(qi.next_page),93    totalLots: total,94    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,95  };96}9798export class BernaertsConnector extends SaleResultsConnector {99  readonly version = '1.0.0';100  readonly house: HouseConfig = { houseName: 'Bernaerts', defaultCurrency: 'EUR', location: 'Antwerp, Belgium', idKey: 'bernaerts_lot', premiumIncluded: false, fallbackSlug: 'antiques', minIntervalMs: 2000, maxPagesPerSale: 30 };101  protected override minIntervalMs = 2000;102103  async listSales(ctx: CrawlContext): Promise<SaleRef[]> {104    const url = String(this.meta.config.pastUrl ?? `${BASE}/auctions/past`);105    await this.throttle(url);106    const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 });107    if (!res.success || !res.html) {108      ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`);109      return [];110    }111    return parsePastAuctions(res.html);112  }113114  salePageUrl(sale: SaleRef, page: number): string {115    return page > 1 ? `${sale.url}?page=${page}` : sale.url;116  }117118  parseSalePage(res: ExtractionResult, sale: SaleRef): ParsedSalePage | null {119    return res.html ? parseLotsPage(res.html, sale) : null;120  }121}122123export default function createConnector(meta: ConnectorMeta) {124  return new BernaertsConnector(meta);125}126