import type { ConnectorMeta, CrawlContext } from '@rareindex/connectors'; import type { ExtractionResult } from '@rareindex/shared'; import { stripHtml } from '../_g8-auctions-eu-apac-lib/index.js'; import { SaleResultsConnector, pick, type HouseConfig, type ParsedLot, type ParsedSalePage, type SaleRef } from '../_g8-auctions-eu-apac-lib/sale-results.js'; /** * Woolley & Wallis (Salisbury, UK) — Umbraco site with a public lot-search JSON API. robots.txt DISALLOWS /umbraco/ * (where the past-sales JSON lives) so we do not touch it: past sales are enumerated from the public /sitemap-xml * (/departments/// pages), the sale page gives the title + date and its first lot page * (/view-lot/1/) exposes `sale-id=""`; lots then come from GET /api/lots/search?saleId=&pageIndex=N&pageSize=100 * (HammerPrice, estimates, LotNumber, Title, FullDescription, image, ViewUrl) — explicitly HAMMER prices, GBP. */ const BASE = 'https://www.woolleyandwallis.co.uk'; const PAGE_SIZE = 100; type ApiLot = { Id?: number; LotNumber?: string; Title?: string; FullDescription?: string; HammerPrice?: number | null; HammerPriceFormatted?: string; LowerEstimate?: number | null; UpperEstimate?: number | null; MainImageUrl?: string | null; ViewUrl?: string | null; SaleNodeId?: number; ConditionReport?: string | null; Categories?: string[]; DroitDeSuite?: boolean; NoBuyersPremium?: boolean; AlternateBuyersPremium?: boolean; LastModified?: string | null }; /** sitemap → sale pages under /departments/// (excluding department roots and view-lot pages). */ export function parseSitemapSales(xml: string): SaleRef[] { const out: SaleRef[] = []; const seen = new Set(); for (const m of xml.matchAll(/\s*([^<\s]+)\s*<\/loc>/g)) { const url = m[1]!.replace(/&/g, '&'); const p = url.match(/^https:\/\/www\.woolleyandwallis\.co\.uk\/departments\/([a-z0-9-]+)\/([a-z0-9-]+)\/?$/i); if (!p || seen.has(p[2]!)) continue; seen.add(p[2]!); out.push({ id: p[2]!, title: p[2]!.toUpperCase(), url: url.endsWith('/') ? url : `${url}/`, date: null, location: 'Salisbury, United Kingdom', extra: { department: p[1]!.replace(/-/g, ' ') } }); } return out; } /** Sale page: "Sale title | 20th August 2026 | Woolley and Wallis" or "20th August 2026. Starts at 10:00am". */ export function parseSalePageHeader(htmlText: string): { title: string | null; date: string | null } { const t = pick(htmlText, /<title>([\s\S]*?)<\/title>/); const h1 = pick(htmlText, /<h1[^>]*>([\s\S]*?)<\/h1>/); const dm = htmlText.match(/(\d{1,2})(?:st|nd|rd|th)?\s+([A-Z][a-z]{2,8})\s+(\d{4})/); let date: string | null = null; if (dm) { const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const mo = months.indexOf(dm[2]!.slice(0, 3).toLowerCase()); if (mo >= 0) date = new Date(Date.UTC(Number(dm[3]), mo, Number(dm[1]))).toISOString(); } const title = h1 ?? t?.split('|')[0]?.trim() ?? null; return { title, date }; } export function parseSaleId(htmlText: string): string | null { return htmlText.match(/sale-id="(\d+)"/)?.[1] ?? htmlText.match(/saleId["']?\s*[:=]\s*["']?(\d+)/)?.[1] ?? null; } export function parseLotsJson(json: unknown, sale: SaleRef, pageIndex: number): ParsedSalePage | null { const j = json as { TotalRecords?: number; TotalPages?: number; Results?: ApiLot[] } | null; if (!j || !Array.isArray(j.Results)) return null; const lots: ParsedLot[] = []; for (const l of j.Results) { const lotNo = String(l.LotNumber ?? '').trim(); const title = stripHtml(l.Title ?? '', 300); if (!lotNo || !title) continue; const price = typeof l.HammerPrice === 'number' && l.HammerPrice > 0 ? l.HammerPrice : null; lots.push({ lotNo, title, subtitle: null, description: stripHtml(l.FullDescription ?? null, 500), url: l.ViewUrl ? `${BASE}${l.ViewUrl}` : `${sale.url}view-lot/${lotNo}/`, image: l.MainImageUrl ?? null, price, currency: 'GBP', premiumIncluded: false, estimateLow: typeof l.LowerEstimate === 'number' && l.LowerEstimate > 0 ? l.LowerEstimate : null, estimateHigh: typeof l.UpperEstimate === 'number' && l.UpperEstimate > 0 ? l.UpperEstimate : null, date: null, sold: price !== null, extra: { ww_lot_id: l.Id ?? null, categories: l.Categories ?? null, condition_report: stripHtml(l.ConditionReport ?? null, 300), droit_de_suite: l.DroitDeSuite ?? null, no_buyers_premium: l.NoBuyersPremium ?? null, hammer_formatted: l.HammerPriceFormatted ?? null }, }); } const totalPages = typeof j.TotalPages === 'number' ? j.TotalPages : null; return { lots, hasMore: totalPages !== null ? pageIndex + 1 < totalPages : lots.length >= PAGE_SIZE, totalLots: typeof j.TotalRecords === 'number' ? j.TotalRecords : null }; } export class WoolleyWallisConnector extends SaleResultsConnector { readonly version = '1.0.0'; readonly house: HouseConfig = { houseName: 'Woolley & Wallis', defaultCurrency: 'GBP', location: 'Salisbury, United Kingdom', idKey: 'woolley_wallis_lot', premiumIncluded: false, fallbackSlug: 'antiques', responseType: 'json', minIntervalMs: 2500, maxPagesPerSale: 20 }; protected override minIntervalMs = 2500; async listSales(ctx: CrawlContext): Promise<SaleRef[]> { const url = String(this.meta.config.sitemapUrl ?? `${BASE}/sitemap-xml`); 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 []; } // sitemap order = site order (newest sales last within a department) → reverse so recent sales come first return parseSitemapSales(res.html).reverse(); } salePageUrl(sale: SaleRef, page: number): string { return `${BASE}/api/lots/search?pageIndex=${page - 1}&filter=&saleId=${String(sale.extra.sale_id ?? '')}&pageSize=${PAGE_SIZE}`; } parseSalePage(res: ExtractionResult, sale: SaleRef, page: number): ParsedSalePage | null { return parseLotsJson(res.json, sale, page - 1); } /** Page 1 first resolves the numeric sale id (sale page → view-lot/1/), then calls the lot-search API. */ protected override async fetchSalePage(ctx: CrawlContext, sale: SaleRef, page: number): Promise<{ url: string; res: ExtractionResult; parsed: ParsedSalePage | null }> { if (!sale.extra.sale_id) { await this.throttle(sale.url); const sp = await ctx.fetch(sale.url, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000 }); if (!sp.success || !sp.html) return { url: sale.url, res: sp, parsed: null }; const header = parseSalePageHeader(sp.html); let saleId = parseSaleId(sp.html); if (!saleId) { const lotUrl = `${sale.url}view-lot/1/`; await this.throttle(lotUrl); const lp = await ctx.fetch(lotUrl, { engines: ['api'], responseType: 'text', minQuality: 0, timeoutMs: 60_000, failOnHttpError: false }); saleId = lp.html ? parseSaleId(lp.html) : null; } if (!saleId) { ctx.anomaly('selector_missing', `${sale.id}: sale-id not found`); return { url: sale.url, res: sp, parsed: null }; } Object.assign(sale, { title: header.title ?? sale.title, date: header.date ?? sale.date, extra: { ...sale.extra, sale_id: saleId } }); } return super.fetchSalePage(ctx, sale, page); } } export default function createConnector(meta: ConnectorMeta) { return new WoolleyWallisConnector(meta); }