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%
6.2 KB · 115 lines typescript
Raw Blame History
1import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors';2import type { ExtractionResult } from '@rareindex/shared';3import { parseEuMoney } from '../_g8-auctions-eu-apac-lib/index.js';4import { SaleResultsConnector, absolute, chunksBetween, pick, textOf, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js';56/**7 * Dunbar Sloane (Wellington / Auckland, NZ) — Tandem Auctions platform (auctions.dunbarsloane.co.nz).8 * /previous-auctions lists past sales (title, "Thursday, 2 - Wednesday, 15 October 2025", /<id>);9 * /<id>/catalogue?page=N shows lot cards (lot number, title, "Estimate: $200 - $350", "Realised: $140 + premium").10 * "Realised … plus premium" = HAMMER in NZD.11 */12const BASE = 'https://auctions.dunbarsloane.co.nz';1314/** "Wednesday, 21 June 2023" | "Thursday, 2 - Wednesday, 15 October 2025" | "Friday, 17 - Tuesday, 28 October 2025" → first day. */15export function parseNzDate(text: string | null | undefined): string | null {16  if (!text) return null;17  const t = textOf(text).replace(/\s+/g, ' ');18  const range = t.match(/(\d{1,2})\s*(?:([A-Za-z]{3,9})\s*)?-\s*(?:[A-Za-z]+,\s*)?(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})/);19  const single = t.match(/(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})/);20  const parts = range ? { d: range[1]!, mon: range[2] ?? range[4]!, y: range[5]! } : single ? { d: single[1]!, mon: single[2]!, y: single[3]! } : null;21  if (!parts) return null;22  const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];23  const mo = months.indexOf(parts.mon.slice(0, 3).toLowerCase());24  if (mo < 0) return null;25  const d = new Date(Date.UTC(Number(parts.y), mo, Number(parts.d)));26  return Number.isNaN(d.getTime()) ? null : d.toISOString();27}2829export function parsePreviousAuctions(htmlText: string): SaleRef[] {30  const out: SaleRef[] = [];31  const seen = new Set<string>();32  for (const chunk of chunksBetween(htmlText, /<div class="card auction-card[^"]*">/)) {33    const id = chunk.match(/href="\/(\d+)"/)?.[1];34    if (!id || seen.has(id)) continue;35    seen.add(id);36    const title = pick(chunk, /<h2 class="card-title">([\s\S]*?)<\/h2>/) ?? `Auction ${id}`;37    const dateText = pick(chunk, /<h3 class="card-subtitle[^"]*">([\s\S]*?)<\/h3>/);38    const badge = pick(chunk, /<span class="badge[^"]*">([\s\S]*?)<\/span>/);39    // timed sales run for two weeks: keep the closing day so the crawler waits for results40    const endText = dateText?.match(/-\s*([A-Za-z]+,\s*\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4})/)?.[1] ?? null;41    out.push({ id, title, url: `${BASE}/${id}/catalogue`, date: parseNzDate(dateText), location: null, extra: { date_text: dateText, auction_type: badge, end_date: endText ? parseNzDate(endText) : null } });42  }43  return out;44}4546export function parseCataloguePage(htmlText: string, sale: SaleRef, page: number): ParsedSalePage | null {47  if (!/class="lot-grid/.test(htmlText) && !/data-ln="/.test(htmlText)) return null;48  const lots: ParsedLot[] = [];49  for (const chunk of chunksBetween(htmlText, /<div id="\d+[A-Za-z]?" class="card h-100" data-ln="/)) {50    const lotNo = chunk.match(/data-ln="([^"]+)"/)?.[1];51    const href = chunk.match(/href="(\/\d+\/catalogue\/[^"]+)"/)?.[1] ?? null;52    if (!lotNo || !href) continue;53    const image = chunk.match(/data-src="([^"]+)"/)?.[1] ?? null;54    const alt = chunk.match(/alt="([^"]*)"/)?.[1] ?? null;55    const textBlock = chunk.match(/<p class="card-text">([\s\S]*?)<small class="estimate">/)?.[1] ?? chunk.match(/<p class="card-text">([\s\S]*?)<\/p>/)?.[1] ?? '';56    const title = textOf(textBlock) || (alt ? textOf(alt) : '');57    if (!title) continue;58    const est = pick(chunk, /<small class="estimate">\s*Estimate:\s*([^<]*)</);59    const estM = est?.match(/\$\s*([\d,]+)\s*-\s*\$?\s*([\d,]+)/);60    const realised = chunk.match(/card-footer realised">\s*Realised:\s*\$?\s*([\d,]+)\s*(<small>[^<]*<\/small>)?/);61    const price = realised ? parseEuMoney(`NZ$${realised[1]}`, 'NZD', 'en') : null;62    const premiumNote = realised?.[2] ? textOf(realised[2]) : null;63    lots.push({64      lotNo,65      title,66      subtitle: null,67      description: null,68      url: absolute(BASE, href)!,69      image,70      price: price?.amount ?? null,71      currency: 'NZD',72      premiumIncluded: premiumNote ? !/\+|plus/i.test(premiumNote) : null,73      estimateLow: estM ? Number(estM[1]!.replace(/,/g, '')) : null,74      estimateHigh: estM ? Number(estM[2]!.replace(/,/g, '')) : null,75      date: null,76      sold: price !== null,77      extra: { premium_note: premiumNote },78    });79  }80  const pageOf = htmlText.match(/Page\s+(\d+)\s+of\s+(\d+)/);81  const total = pageOf ? Number(pageOf[2]) : null;82  const header = pick(htmlText, /<h1 class="page-header">([\s\S]*?)<small/);83  const dateText = pick(htmlText, /<small class="text-muted">([\s\S]*?)<\/small>/);84  return { lots, hasMore: total !== null ? page < total : false, totalLots: null, sale: { title: header ?? undefined, date: parseNzDate(dateText) ?? undefined, extra: { pages: total } } };85}8687export class DunbarSloaneConnector extends SaleResultsConnector {88  readonly version = '1.0.0';89  readonly house: HouseConfig = { houseName: 'Dunbar Sloane', defaultCurrency: 'NZD', location: 'Wellington, New Zealand', idKey: 'dunbar_sloane_lot', premiumIncluded: false, fallbackSlug: 'antiques', minIntervalMs: 2500, maxPagesPerSale: 25 };90  protected override minIntervalMs = 2500;9192  async listSales(ctx: CrawlContext): Promise<SaleRef[]> {93    const url = String(this.meta.config.previousUrl ?? `${BASE}/previous-auctions`);94    await this.throttle(url);95    const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 });96    if (!res.success || !res.html) {97      ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`);98      return [];99    }100    return parsePreviousAuctions(res.html);101  }102103  salePageUrl(sale: SaleRef, page: number): string {104    return page > 1 ? `${sale.url}?page=${page}` : sale.url;105  }106107  parseSalePage(res: ExtractionResult, sale: SaleRef, page: number): ParsedSalePage | null {108    return res.html ? parseCataloguePage(res.html, sale, page) : null;109  }110}111112export default function createConnector(meta: ConnectorMeta) {113  return new DunbarSloaneConnector(meta);114}115