TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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(/&/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(/&/g, '&')) ?? sale.url,88 image: image ? image.replace(/&/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