import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors'; import type { ExtractionResult } from '@rareindex/shared'; import { parseEuMoney } from '../_g8-auctions-eu-apac-lib/index.js'; import { SaleResultsConnector, absolute, chunksBetween, pick, textOf, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js'; /** * Leonard Joel (Melbourne / Sydney / Brisbane) — classic-ASP catalogue site auctions.leonardjoel.com.au. * /auction-results lists past sales (sale_no, title, "Monday 7 Sep 2026, 10:00am", venue); * /custom_asp/searchresults.asp?type=result&st=D&pg=N&ps=100&sale_no= lists 100 lots per page with * "Lot 201", title, "Estimate: $4,600 - 5,500" and "Sold for $8,500" (AUD). Prices are labelled "Sold for" only. */ const BASE = 'https://auctions.leonardjoel.com.au'; const PAGE = 100; /** "Monday 7 Sep 2026, 10:00am" | "17 August 2026, 6:30pm" → ISO (UTC midnight). */ export function parseAuDate(text: string | null | undefined): string | null { const m = text?.replace(/\s+/g, ' ').match(/(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})/); if (!m) return null; const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const mo = months.indexOf(m[2]!.slice(0, 3).toLowerCase()); if (mo < 0) return null; return new Date(Date.UTC(Number(m[3]), mo, Number(m[1]))).toISOString(); } export function parseResultsIndex(htmlText: string): SaleRef[] { const out: SaleRef[] = []; const seen = new Set(); for (const chunk of chunksBetween(htmlText, /
([\s\S]*?)<\/div>/)?.[1] ?? ''; const dateText = [...details.matchAll(/

([\s\S]*?)<\/p>/g)].map((m) => textOf(m[1])).find((t) => /\d{4}/.test(t)) ?? null; const venue = pick(details, /

([\s\S]*?)<\/p>/) ?? null; 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 } }); } return out; } export function parseResultsPage(htmlText: string, sale: SaleRef, page: number): ParsedSalePage | null { if (!/auction-collection-item/.test(htmlText) && !/searchresults\.asp/.test(htmlText)) return null; const lots: ParsedLot[] = []; for (const chunk of chunksBetween(htmlText, /

\s*Lot\s*([^<]*)<\/h6>/); const title = pick(chunk, /

([\s\S]*?)<\/h4>/); if (!lotNo || !title) continue; const refno = href?.match(/refno=(\d+)/)?.[1] ?? null; const image = chunk.match(/background-image:\s*url\(([^)]+)\)/)?.[1]?.replace(/['"]/g, '') ?? null; const estText = pick(chunk, /Estimate:\s*(?:)?\s*([^<]+)\s*(Sold for[^<]*)<\/b>/i); const price = soldText ? parseEuMoney(soldText.replace(/^Sold for\s*/i, '').replace('$', 'A$'), 'AUD', 'en') : null; lots.push({ lotNo: lotNo.trim(), title: title.replace(/\.\.\.$/, '').trim(), subtitle: null, description: null, url: absolute(BASE, href) ?? sale.url, image: image ? absolute(BASE, image) : null, price: price?.amount ?? null, currency: 'AUD', premiumIncluded: null, estimateLow: estM ? Number(estM[1]!.replace(/,/g, '')) : null, estimateHigh: estM ? Number(estM[2]!.replace(/,/g, '')) : null, date: null, sold: price !== null, extra: { refno, sold_text: soldText, truncated_title: /\.\.\.$/.test(title) }, }); } const hasNext = new RegExp(`[?&]pg=${page + 1}&`).test(htmlText) || /→/.test(htmlText.slice(htmlText.indexOf('class="pagination'))); const header = htmlText.match(/Sale:\s*([A-Z]{1,3}\d+)\s*
\s*([^<]+?)\s*([^<]*)<\/em>/); const title = pick(htmlText, /]*>([\s\S]*?)<\/h1>/); 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 } }; } export class LeonardJoelConnector extends SaleResultsConnector { readonly version = '1.0.0'; readonly house: HouseConfig = { houseName: 'Leonard Joel', defaultCurrency: 'AUD', location: 'Melbourne, Australia', idKey: 'leonard_joel_lot', premiumIncluded: null, fallbackSlug: 'antiques', minIntervalMs: 2500, maxPagesPerSale: 20 }; protected override minIntervalMs = 2500; async listSales(ctx: CrawlContext): Promise { const url = String(this.meta.config.resultsUrl ?? `${BASE}/auction-results`); 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 parseResultsIndex(res.html); } salePageUrl(sale: SaleRef, page: number): string { return `${BASE}/custom_asp/searchresults.asp?type=result&st=D&pg=${page}&ps=${PAGE}&sale_no=${sale.id}`; } parseSalePage(res: ExtractionResult, sale: SaleRef, page: number): ParsedSalePage | null { return res.html ? parseResultsPage(res.html, sale, page) : null; } } export default function createConnector(meta: ConnectorMeta) { return new LeonardJoelConnector(meta); }