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 * Leonard Joel (Melbourne / Sydney / Brisbane) — classic-ASP catalogue site auctions.leonardjoel.com.au.8 * /auction-results lists past sales (sale_no, title, "Monday 7 Sep 2026, 10:00am", venue);9 * /custom_asp/searchresults.asp?type=result&st=D&pg=N&ps=100&sale_no=<id> lists 100 lots per page with10 * "Lot 201", title, "Estimate: $4,600 - 5,500" and "Sold for $8,500" (AUD). Prices are labelled "Sold for" only.11 */12const BASE = 'https://auctions.leonardjoel.com.au';13const PAGE = 100;1415/** "Monday 7 Sep 2026, 10:00am" | "17 August 2026, 6:30pm" → ISO (UTC midnight). */16export function parseAuDate(text: string | null | undefined): string | null {17 const m = text?.replace(/\s+/g, ' ').match(/(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})/);18 if (!m) return null;19 const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];20 const mo = months.indexOf(m[2]!.slice(0, 3).toLowerCase());21 if (mo < 0) return null;22 return new Date(Date.UTC(Number(m[3]), mo, Number(m[1]))).toISOString();23}2425export function parseResultsIndex(htmlText: string): SaleRef[] {26 const out: SaleRef[] = [];27 const seen = new Set<string>();28 for (const chunk of chunksBetween(htmlText, /<section class="flexible-auction-calendar-item/)) {29 const saleNo = chunk.match(/sale_no=([A-Z]{1,3}\d+)/)?.[1];30 if (!saleNo || seen.has(saleNo)) continue;31 seen.add(saleNo);32 const title = pick(chunk, /<h3>([\s\S]*?)<\/h3>/) ?? saleNo;33 const details = chunk.match(/auction-details">([\s\S]*?)<\/div>/)?.[1] ?? '';34 const dateText = [...details.matchAll(/<p>([\s\S]*?)<\/p>/g)].map((m) => textOf(m[1])).find((t) => /\d{4}/.test(t)) ?? null;35 const venue = pick(details, /<p class="online">([\s\S]*?)<\/p>/) ?? null;36 out.push({ id: saleNo, title, url: `${BASE}/custom_asp/searchresults.asp?type=result&st=D&pg=1&ps=${PAGE}&sale_no=${saleNo}`, date: parseAuDate(dateText), location: venue ? venue.replace(/^.*\(([^)]+)\).*$/, '$1') : null, extra: { date_text: dateText, venue } });37 }38 return out;39}4041export function parseResultsPage(htmlText: string, sale: SaleRef, page: number): ParsedSalePage | null {42 if (!/auction-collection-item/.test(htmlText) && !/searchresults\.asp/.test(htmlText)) return null;43 const lots: ParsedLot[] = [];44 for (const chunk of chunksBetween(htmlText, /<section class="flexible-auction-item auction-collection-item/)) {45 const href = chunk.match(/href="([^"]*fullCatalogue\.asp[^"]*)"/)?.[1]?.replace(/&/g, '&').trim() ?? null;46 const lotNo = pick(chunk, /<h6>\s*Lot\s*([^<]*)<\/h6>/);47 const title = pick(chunk, /<h4>([\s\S]*?)<\/h4>/);48 if (!lotNo || !title) continue;49 const refno = href?.match(/refno=(\d+)/)?.[1] ?? null;50 const image = chunk.match(/background-image:\s*url\(([^)]+)\)/)?.[1]?.replace(/['"]/g, '') ?? null;51 const estText = pick(chunk, /Estimate:\s*(?:<br\s*\/?>)?\s*([^<]+)</);52 const estM = estText?.replace(/ /g, ' ').match(/\$?\s*([\d,]+)\s*-\s*\$?\s*([\d,]+)/);53 const soldText = pick(chunk, /<b>\s*(Sold for[^<]*)<\/b>/i);54 const price = soldText ? parseEuMoney(soldText.replace(/^Sold for\s*/i, '').replace('$', 'A$'), 'AUD', 'en') : null;55 lots.push({56 lotNo: lotNo.trim(),57 title: title.replace(/\.\.\.$/, '').trim(),58 subtitle: null,59 description: null,60 url: absolute(BASE, href) ?? sale.url,61 image: image ? absolute(BASE, image) : null,62 price: price?.amount ?? null,63 currency: 'AUD',64 premiumIncluded: null,65 estimateLow: estM ? Number(estM[1]!.replace(/,/g, '')) : null,66 estimateHigh: estM ? Number(estM[2]!.replace(/,/g, '')) : null,67 date: null,68 sold: price !== null,69 extra: { refno, sold_text: soldText, truncated_title: /\.\.\.$/.test(title) },70 });71 }72 const hasNext = new RegExp(`[?&]pg=${page + 1}&`).test(htmlText) || /→/.test(htmlText.slice(htmlText.indexOf('class="pagination')));73 const header = htmlText.match(/Sale:\s*([A-Z]{1,3}\d+)\s*<br>\s*([^<]+?)\s*<em>([^<]*)<\/em>/);74 const title = pick(htmlText, /<h1[^>]*>([\s\S]*?)<\/h1>/);75 return { lots, hasMore: hasNext && lots.length >= PAGE, totalLots: null, sale: { title: title ?? undefined, date: header ? parseAuDate(header[2]) ?? undefined : undefined, location: header ? textOf(header[3]) || undefined : undefined } };76}7778export class LeonardJoelConnector extends SaleResultsConnector {79 readonly version = '1.0.0';80 readonly house: HouseConfig = { houseName: 'Leonard Joel', defaultCurrency: 'AUD', location: 'Melbourne, Australia', idKey: 'leonard_joel_lot', premiumIncluded: null, fallbackSlug: 'antiques', minIntervalMs: 2500, maxPagesPerSale: 20 };81 protected override minIntervalMs = 2500;8283 async listSales(ctx: CrawlContext): Promise<SaleRef[]> {84 const url = String(this.meta.config.resultsUrl ?? `${BASE}/auction-results`);85 await this.throttle(url);86 const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 });87 if (!res.success || !res.html) {88 ctx.anomaly('past_list_failed', `${url}: ${res.error ?? res.httpStatus}`);89 return [];90 }91 return parseResultsIndex(res.html);92 }9394 salePageUrl(sale: SaleRef, page: number): string {95 return `${BASE}/custom_asp/searchresults.asp?type=result&st=D&pg=${page}&ps=${PAGE}&sale_no=${sale.id}`;96 }9798 parseSalePage(res: ExtractionResult, sale: SaleRef, page: number): ParsedSalePage | null {99 return res.html ? parseResultsPage(res.html, sale, page) : null;100 }101}102103export default function createConnector(meta: ConnectorMeta) {104 return new LeonardJoelConnector(meta);105}106