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.8 KB · 115 lines typescript
Raw Blame History
1/**2 * Parser for the "goauction" UK auction-house platform (Sworders, Chiswick Auctions, Dominic Winter …):3 *  - results calendar: `.auction-calendar-item` blocks with title, "Tuesday 21 April 2026" (or a range), sale number4 *    and a link carrying `au=<auctionId>`;5 *  - lot grid: `.auction-grid-lot` / `.auction-lot` cards with "Lot 12 - Title", optional `.sub-title`, image,6 *    "Sold for £1,800" (only sold lots carry a price; unsold lots show nothing), 48–96 per page, `<link rel="next">`.7 * Prices are what the house publishes as "Sold for" — the pages do not say hammer vs premium, so the basis is8 * left to each connector (null = unknown unless the house's terms say otherwise).9 */10import type { CurrencyCode } from '@rareindex/shared';11import { parseEuMoney } from './index.js';12import { absolute, chunksBetween, pick, textOf, type ParsedLot, type ParsedSalePage, type SaleRef } from './sale-results.js';1314export interface GoauctionHouse {15  base: string;16  currency: CurrencyCode;17  /** path of the lot-list page: 'details' → /auction/details/<slug>/?au=ID ; 'search' → /Auction/Search?au=ID&sd=2 */18  listStyle: 'details' | 'search';19}2021/**22 * "Tuesday 21 April 2026" | "Tuesday 1 September - Monday 7 September 2026" | "20th August 2026" | "4th Sep, 2026 12:00"23 * → first day ISO (UTC midnight).24 */25export function parseUkDate(text: string | null | undefined): string | null {26  if (!text) return null;27  const t = text.replace(/\s+/g, ' ').trim();28  const range = t.match(/(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3,9})?,?\s*-\s*[A-Za-z]*,?\s*(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3,9}),?\s+(\d{4})/);29  const m = range ? { d: range[1]!, mon: range[2] ?? range[4]!, y: range[5]! } : (() => {30    const s = t.match(/(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3,9})\.?,?\s+(\d{4})/);31    return s ? { d: s[1]!, mon: s[2]!, y: s[3]! } : null;32  })();33  if (!m) return null;34  const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];35  const mo = months.indexOf(m.mon.slice(0, 3).toLowerCase());36  if (mo < 0) return null;37  const d = new Date(Date.UTC(Number(m.y), mo, Number(m.d)));38  return Number.isNaN(d.getTime()) ? null : d.toISOString();39}4041export function parseGoauctionCalendar(htmlText: string, house: GoauctionHouse): SaleRef[] {42  const out: SaleRef[] = [];43  const seen = new Set<string>();44  for (const chunk of chunksBetween(htmlText, /<div class="auction-calendar-item[^"]*"/)) {45    const au = chunk.match(/[?&](?:amp;)?au=(\d+)/)?.[1];46    if (!au || seen.has(au)) continue;47    const href = chunk.match(/href=['"]([^'"]*[?&](?:amp;)?au=\d+[^'"]*)['"]/)?.[1] ?? null;48    if (!href) continue;49    seen.add(au);50    const title = pick(chunk, /<H[3-6]>([\s\S]*?)<\/H[3-6]>/i) ?? pick(chunk, /alt="([^"]*)"/) ?? `Auction ${au}`;51    // Sworders: <h5>Tuesday 21 April 2026</h5>; Chiswick (timed): "Starts: 24th Aug, 2026 17:00 … Ends: 4th Sep, 2026 12:00";52    // Dominic Winter (sessions): "Lot: 1 to 410 - 2nd Sep, 2026 10:00" → first session day.53    const text = textOf(chunk.replace(/<a[^>]*>|<\/a>/g, ' '));54    const startsText = text.match(/Starts:\s*(\d{1,2}(?:st|nd|rd|th)?\s+[A-Za-z]{3,9},?\s+\d{4})/i)?.[1] ?? null;55    const endsText = text.match(/Ends:\s*(\d{1,2}(?:st|nd|rd|th)?\s+[A-Za-z]{3,9},?\s+\d{4})/i)?.[1] ?? null;56    const dateText = pick(chunk, /<h5>([\s\S]*?)<\/h5>/) ?? pick(chunk, /<p class="auction-calendar-date">([\s\S]*?)<\/p>/) ?? startsText ?? text.match(/((?:\d{1,2}(?:st|nd|rd|th)?\s+[A-Za-z]+,?\s*-\s*)?\d{1,2}(?:st|nd|rd|th)?\s+[A-Za-z]{3,9},?\s+\d{4})/)?.[1] ?? null;57    const saleNo = pick(chunk, /Sale number:\s*([A-Z0-9]+)/i);58    const timed = /timed online|Starts:/i.test(chunk);59    const resultsPublished = /View Results|Download PDF results/i.test(chunk);60    out.push({ id: au, title, url: absolute(house.base, href.replace(/&amp;/g, '&')) ?? `${house.base}/auction/details/?au=${au}`, date: parseUkDate(dateText), location: null, extra: { sale_number: saleNo, timed, date_text: dateText, end_date: parseUkDate(endsText), results_published: resultsPublished } });61  }62  return out;63}6465export function parseGoauctionLots(htmlText: string, sale: SaleRef, house: GoauctionHouse): ParsedSalePage | null {66  if (!/class="auction-lot"|class='auction-grid-lot|class="auction-lot-title"/.test(htmlText)) return null;67  const lots: ParsedLot[] = [];68  for (const chunk of chunksBetween(htmlText, /<div class="auction-lot">/, /<nav class="pagination">|<footer|id="footer"/)) {69    const href = chunk.match(/href="([^"]*\/auction\/lot\/[^"]+)"/i)?.[1] ?? null;70    const titleBlock = chunk.match(/<span class='lot-title[^']*'>([\s\S]*?)<\/span>\s*<\/a>/)?.[1] ?? chunk.match(/<p class="auction-lot-title">([\s\S]*?)<\/p>/)?.[1] ?? null;71    if (!titleBlock) continue;72    const sub = pick(titleBlock, /<span class='sub-title[^']*'>([\s\S]*?)<\/span>/);73    const main = textOf(titleBlock.replace(/<span class='sub-title[^']*'>[\s\S]*?<\/span>/, ''));74    const lm = main.match(/^Lot\s+(\S+?)\s*(?:-|–|\s)\s*([\s\S]*)$/i);75    const lotNo = lm?.[1]?.replace(/[,:]$/, '') ?? chunk.match(/alt="(?:Lot\s+)?(\d+[A-Za-z]?)\s*-/)?.[1] ?? null;76    const title = (lm?.[2] ?? main).trim();77    if (!lotNo || !title) continue;78    const soldText = pick(chunk, /<strong[^>]*>\s*(Sold for[^<]*)<\/strong>/i);79    const price = soldText ? parseEuMoney(soldText.replace(/^Sold for\s*/i, ''), house.currency, 'en') : null;80    const image = chunk.match(/<img (?:src|data-lazy)="([^"]+)"/)?.[1] ?? null;81    const lotId = href?.match(/[?&](?:amp;)?lot=(\d+)/)?.[1] ?? null;82    lots.push({83      lotNo,84      title,85      subtitle: sub || null,86      description: null,87      url: absolute(house.base, href?.replace(/&amp;/g, '&')) ?? sale.url,88      image: image ? image.replace(/&amp;/g, '&') : null,89      price: price?.amount ?? null,90      currency: price?.currency ?? house.currency,91      premiumIncluded: null,92      estimateLow: null,93      estimateHigh: null,94      date: null,95      sold: price !== null,96      extra: { platform_lot_id: lotId, sold_text: soldText },97    });98  }99  const next = /<link rel="next" href="[^"]+"/.test(htmlText) || /class="next"[^>]*href=|rel="next"/.test(htmlText);100  const header = pick(htmlText, /<h1[^>]*>([\s\S]*?)<\/h1>/);101  const dateText = pick(htmlText, /((?:\d{1,2}(?:st|nd|rd|th)?\s+[A-Za-z]+\s*-\s*)?\d{1,2}(?:st|nd|rd|th)?\s+[A-Za-z]{3,9}\s+\d{4})\s*\|/);102  return { lots, hasMore: next, totalLots: null, sale: { title: header && header.length < 160 ? header : undefined, date: parseUkDate(dateText) ?? undefined } };103}104105export function goauctionPageUrl(sale: SaleRef, page: number, house: GoauctionHouse): string {106  const u = new URL(sale.url);107  if (house.listStyle === 'search') {108    const au = u.searchParams.get('au') ?? sale.id;109    return `${house.base}/Auction/Search?au=${au}&sd=2${page > 1 ? `&pn=${page}` : ''}&g=1`;110  }111  u.searchParams.set('g', '1');112  if (page > 1) u.searchParams.set('pn', String(page));113  return u.toString();114}115