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%
5.9 KB · 106 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 * 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(/&amp;/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) || /&rarr;/.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