import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import type { NormalizedRecord, NormalizedSale } from '@rareindex/shared'; import { makeSale } from '../_carlib/index.js'; import { clean, isNumisBundle, numisAttributes, numisCategory, parseAuctionDate, parseCoinGrade, realized, type NumisSlug } from '../_g5-numismatics-lib/index.js'; /** * Noonans Mayfair (ex Dix Noonan Webb) — coins, banknotes, medals and tokens; archive of every sale * since 1991 with hammer prices. Public pages: /archive/past-catalogues/ (all catalogues, newest first) * → /archive/past-catalogues//catalogue/?layout=list&offset=N (40 lots per page: full description with * estimate, hammer price or "Unsold", lot id, image). Engine: Firecrawl (Cloudflare challenge on plain HTTPS). */ const SITE = 'https://www.noonans.co.uk'; const PARSER_VERSION = '1.0.0'; const PAGE_SIZE = 40; export const AuctionSchema = z.object({ id: z.string(), title: z.string(), dateText: z.string().nullable() }); export const LotSchema = z.object({ lotId: z.string(), lotNumber: z.string(), url: z.string(), description: z.string(), estimateText: z.string().nullable(), hammerText: z.string().nullable(), image: z.string().nullable(), }); export const PayloadSchema = z.object({ kind: z.literal('catalogue_page'), auction: AuctionSchema, url: z.string(), offset: z.number().int(), totalLots: z.number().int().nullable(), lots: z.array(LotSchema), }); export type Payload = z.infer; /** Past-catalogues index → auctions (newest first). */ export function parsePastCatalogues(htmlText: string): z.infer[] { const $ = H.load(htmlText); const out: z.infer[] = []; $('.card-component--past-catalogues').each((_, el) => { const e = $(el); const href = e.find('a.card-component__full-card-link').attr('href') ?? ''; const id = href.match(/past-catalogues\/(\d+)\//)?.[1]; if (!id || out.some((a) => a.id === id)) return; const title = clean(e.find('.card-component__title').first().text()); const dateText = clean(e.find('.card-component__text--secondary').first().text()) || null; if (title) out.push({ id, title, dateText }); }); return out; } /** Catalogue list page (layout=list) → lots. */ export function parseCataloguePage(htmlText: string, auction: z.infer, url: string): Payload { const $ = H.load(htmlText); const offset = Number(url.match(/offset=(\d+)/)?.[1] ?? 0); const found = $('.pagination-top__filter-text--lower').first().text().match(/(\d[\d,]*)\s+lots?\s+found/i); const lots: z.infer[] = []; $('.card-component-list, .card-component--lot').each((_, el) => { const e = $(el); const a = e.find('a.card-component-list__full-card-link, a.card-component__full-card-link').first(); const href = a.attr('href') ?? ''; const lotId = href.match(/catalogue\/(\d+)\//)?.[1]; if (!lotId || lots.some((l) => l.lotId === lotId)) return; const lotNumber = clean(e.find('.card-component__title--lot-number').first().text()).replace(/^№\s*/, ''); const raw = clean(e.find('.card-component-list__info-text, .card-component-text--five-line').first().text()); if (!lotNumber || !raw) return; // list layout ends the description with the estimate after a tab: "... Fine or better £90-£120" const em = raw.match(/\s((?:£|\$|€|HK\$|S\$|US\$)\s?[\d,.]+(?:\s*[-–]\s*(?:£|\$|€|HK\$|S\$|US\$)?\s?[\d,.]+)?)\s*$/); const estimateText = em ? em[1]!.trim() : null; const description = em ? raw.slice(0, em.index).trim() : raw; const hammerRaw = clean(e.find('.card-component__estimate').first().text()); const hm = hammerRaw.match(/Hammer Price:\s*(.+)$/i); lots.push({ lotId, lotNumber, url: `${SITE}/archive/past-catalogues/${auction.id}/catalogue/${lotId}/`, description, estimateText, hammerText: hm ? hm[1]!.trim() : null, image: e.find('img.card-component__image').attr('src') ?? null, }); }); return { kind: 'catalogue_page', auction, url, offset, totalLots: found ? Number(found[1]!.replace(/,/g, '')) : null, lots }; } /** Catalogue title → default taxonomy slug (null = not a numismatic sale, skipped). */ export function noonansDefaultSlug(title: string): NumisSlug | null { const t = title.toLowerCase(); if (/banknote|paper money/.test(t)) return 'banknotes'; if (/orders, decorations|medals and militaria|\bmilitaria\b|gallantry/.test(t)) return 'medals'; if (/medallion|historical medal|token|ticket|passes/.test(t) && !/coin/.test(t)) return 'medals'; if (/coin|numismatic|sovereign|celtic|roman|hammered|detectorist|treasure|collection of/.test(t)) return 'coins'; if (/jewell|watch|silver|vertu|objects/.test(t)) return null; return null; } interface Cursor { doneAuctions?: string[]; inProgress?: { auction: z.infer; offset: number; totalLots: number | null } | null; done?: boolean; updatedAt?: string; } export class NoonansConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2500; override readonly urlPatterns = [/noonans\.co\.uk\/archive\/past-catalogues\/\d+/i]; async *crawl(ctx: CrawlContext): AsyncIterable { const auctionsPerRun = Number(this.meta.config.auctionsPerRun ?? 1); const pagesPerRun = Number(this.meta.config.pagesPerRun ?? 20); const cursor = { ...((ctx.options.cursor ?? {}) as Cursor) }; const done = new Set(cursor.doneAuctions ?? []); let pages = 0; let yielded = 0; let finished = 0; const fetchPage = (url: string) => { const isIndex = /\/archive\/past-catalogues\/?(?:$|[?#])/.test(url); return ctx.fetch(url, { engines: ['firecrawl', 'scrapfly'], expect: isIndex ? ['title'] : ['title', 'price'], timeoutMs: isIndex ? 120_000 : undefined, parse: (r) => { if (!r.html) return null; if (isIndex) return { title: parsePastCatalogues(r.html)[0]?.title ?? null }; const lot = parseCataloguePage(r.html, { id: '0', title: '', dateText: null }, url).lots[0]; return lot ? { title: lot.description, price: lot.hammerText ?? lot.estimateText } : null; }, }); }; await this.throttle(); const index = await fetchPage(`${SITE}/archive/past-catalogues/`); pages++; if (!index.success || !index.html) { ctx.anomaly('page_fetch_failed', `past-catalogues: ${index.error ?? index.httpStatus}`); return; } const all = parsePastCatalogues(index.html); if (!all.length) { ctx.anomaly('selector_missing', 'past-catalogues: no catalogue cards'); return; } const numismatic = all.filter((a) => noonansDefaultSlug(a.title) !== null); const queue = [...(cursor.inProgress ? [cursor.inProgress.auction] : []), ...numismatic.filter((a) => !done.has(a.id) && a.id !== cursor.inProgress?.auction.id)]; if (ctx.options.mode !== 'backfill') queue.splice(auctionsPerRun + 1); // incremental: newest sales only for (const auction of queue) { if (ctx.signal?.aborted || finished >= auctionsPerRun || pages >= pagesPerRun || this.reached(ctx, yielded)) break; let state = cursor.inProgress?.auction.id === auction.id ? cursor.inProgress : { auction, offset: 0, totalLots: null as number | null }; for (;;) { if (ctx.signal?.aborted || pages >= pagesPerRun || this.reached(ctx, yielded)) break; const url = `${SITE}/archive/past-catalogues/${auction.id}/catalogue/?layout=list&offset=${state.offset}`; await this.throttle(); const res = await fetchPage(url); pages++; if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); break; } const payload = parseCataloguePage(res.html, auction, url); if (!payload.lots.length) { if (state.offset === 0) ctx.anomaly('parse_failure_page', `${url}: no lot cards`); state = { ...state, offset: -1 }; break; } state = { auction, offset: state.offset, totalLots: payload.totalLots ?? state.totalLots }; if (payload.lots.some((l) => l.hammerText && !/unsold/i.test(l.hammerText))) { yielded++; yield { url, externalId: `auction:${auction.id}:offset:${payload.offset}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } const next = state.offset + PAGE_SIZE; const more = payload.lots.length >= PAGE_SIZE && (state.totalLots === null || next < state.totalLots); if (!more) { state = { ...state, offset: -1 }; break; } state = { ...state, offset: next }; cursor.inProgress = state; await ctx.setCursor({ ...cursor, doneAuctions: [...done].slice(-800), updatedAt: new Date().toISOString() }); } if (state.offset === -1) { done.add(auction.id); finished++; cursor.inProgress = null; if (ctx.options.mode === 'backfill') { const idx = numismatic.findIndex((a) => a.id === auction.id); await ctx.progress({ page: idx + 1, totalPages: numismatic.length, itemsProcessed: yielded, reachedDate: parseAuctionDate(auction.dateText) }); } } else cursor.inProgress = state; await ctx.setCursor({ ...cursor, doneAuctions: [...done].slice(-800), updatedAt: new Date().toISOString() }); } if (ctx.options.mode === 'backfill' && numismatic.every((a) => done.has(a.id))) { await ctx.setCursor({ ...cursor, done: true, doneAuctions: [...done].slice(-800), updatedAt: new Date().toISOString() }); } } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const saleDate = parseAuctionDate(p.auction.dateText); if (!saleDate) return []; const fallback = noonansDefaultSlug(p.auction.title) ?? 'coins'; const out: NormalizedSale[] = []; for (const lot of p.lots) { const price = realized(lot.hammerText, 'GBP'); if (!price) continue; // Unsold / withdrawn const categorySlug = numisCategory(lot.description, p.auction.title, fallback); const g = parseCoinGrade(lot.description); const est = lot.estimateText ? realized(lot.estimateText.split(/[-–]/)[0]!, price.currency) : null; const estHigh = lot.estimateText?.includes('-') || lot.estimateText?.includes('–') ? realized(lot.estimateText.split(/[-–]/).pop()!, price.currency) : null; const attributes = numisAttributes({ categorySlug, title: lot.description, section: p.auction.title, identifiers: { noonans_lot: lot.lotId }, metadata: { auction_id: p.auction.id, auction_title: p.auction.title, estimate_low: est?.amount ?? null, estimate_high: estHigh?.amount ?? est?.amount ?? null, estimate_currency: est?.currency ?? null, hammer_price: price.amount, buyer_premium: 'excluded (Noonans publishes hammer prices)' }, }); const sale = makeSale({ meta: this.meta, sourceUrl: lot.url, externalId: lot.lotId, rawTitle: lot.description.length > 240 ? `${lot.description.slice(0, 239)}…` : lot.description, description: lot.description, attributes, price: price.amount, currency: price.currency, saleDate, buyerPremiumIncluded: false, auctionHouse: 'Noonans Mayfair', lotNumber: lot.lotNumber, imageUrls: lot.image ? [lot.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, confidence: g.grader ? 0.88 : 0.8, isBundle: isNumisBundle(lot.description), conditionRaw: g.conditionRaw, location: 'GB', }); sale.grade = { grader: g.grader, grade: g.grade, qualifier: g.qualifier, certificationNumber: g.certificationNumber }; out.push(sale); } return out; } } export default (meta: ConnectorMeta) => new NoonansConnector(meta);