TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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