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 '../../firecrawl/_carlib/index.js'; import { clean, isNumisBundle, numisAttributes, numisCategory, parseAuctionDate, parseCoinGrade, realized } from '../../firecrawl/_g5-numismatics-lib/index.js'; /** * Noble Numismatics (Sydney) — every sale since 1994 publishes a single static "Prices Realised" page * listing all lots (description, grading text, price in AUD or "Passed in"). Plain HTTPS, no JS. * The page states "All prices exclude buyer and vendor premiums" → hammer prices. */ const SITE = 'https://www.noble.com.au'; const PARSER_VERSION = '1.0.0'; const CHUNK = 250; export const SaleSchema = z.object({ number: z.string(), title: z.string(), dateText: z.string().nullable(), city: z.string().nullable() }); export const LotSchema = z.object({ lotId: z.string(), lotNumber: z.string(), headline: z.string().nullable(), description: z.string(), gradingText: z.string().nullable(), priceText: z.string().nullable(), status: z.string().nullable(), }); export const PayloadSchema = z.object({ kind: z.literal('prices_realised'), sale: SaleSchema, url: z.string(), premiumNote: z.string().nullable(), chunk: z.number().int(), totalLots: z.number().int(), lots: z.array(LotSchema), }); export type Payload = z.infer; export interface SaleIndexEntry extends z.infer { hasPrices: boolean; } /** /auctions → past sales (newest first). */ export function parseSalesIndex(htmlText: string): SaleIndexEntry[] { const $ = H.load(htmlText); const out: SaleIndexEntry[] = []; $('li.past-sale-row').each((_, li) => { const e = $(li); const number = e.attr('data-sale-number') ?? clean(e.find('.past-sale-row__label').text()).replace(/^Sale\s+/i, ''); if (!number || out.some((s) => s.number === number)) return; const title = clean(e.find('.past-sale-row__title').first().text()) || `Sale ${number}`; const dateText = clean(e.find('.past-sale-row__date').first().text()) || null; const city = clean(e.find('.past-sale-row__city').first().text()) || null; const hasPrices = e.find(`a[href*="/auctions/sale/${number}/prices-realised"]`).length > 0; out.push({ number, title, dateText, city, hasPrices }); }); return out; } export interface PricesRealisedPage { sale: z.infer; premiumNote: string | null; lots: z.infer[]; } /** /auctions/sale//prices-realised → header + every lot row. */ export function parsePricesRealised(htmlText: string, number: string): PricesRealisedPage | null { const $ = H.load(htmlText); const title = clean($('.prices-realised__sale-title').first().text()); if (!title) return null; const dateText = clean($('.prices-realised__dates').first().text()) || null; const city = clean($('.prices-realised__venue').first().text()) || null; const premiumNote = clean($('.prices-realised__total-note').first().text()) || null; const lots: z.infer[] = []; $('table.prices-realised__table tbody tr').each((_, tr) => { const row = $(tr); const a = row.find('td.prices-realised__cell-lot a').first(); const lotId = a.attr('href')?.match(/[?&]id=(\d+)/)?.[1]; const lotNumber = clean(a.text()).replace(/^Lot\s+/i, ''); if (!lotId || !lotNumber) return; const desc = row.find('td.prices-realised__cell-desc').first(); const headline = clean(desc.find('strong').first().text()).replace(/,\s*$/, '') || null; const gradingText = clean(desc.find('em').first().text()) || null; const description = clean(desc.html() ?? ''); const priceCell = row.find('td.prices-realised__cell-price').first(); const status = clean(priceCell.find('.prices-realised__status').first().text()) || null; const priceText = status ? null : clean(priceCell.text()) || null; lots.push({ lotId, lotNumber, headline, description, gradingText, priceText, status }); }); return { sale: { number, title, dateText, city }, premiumNote, lots }; } interface Cursor { doneSales?: string[]; done?: boolean; updatedAt?: string; } export class NobleNumismaticsConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; override readonly urlPatterns = [/noble\.com\.au\/auctions\/(?:sale|lot)/i]; async *crawl(ctx: CrawlContext): AsyncIterable { const salesPerRun = Number(this.meta.config.salesPerRun ?? 1); const cursor = { ...((ctx.options.cursor ?? {}) as Cursor) }; const done = new Set(cursor.doneSales ?? []); await this.throttle(); const index = await ctx.fetch(`${SITE}/auctions`, { engines: ['api'], responseType: 'text', expect: ['title', 'date'], parse: (r) => ({ title: r.html ? parseSalesIndex(r.html)[0]?.title ?? null : null, date: r.html ? parseSalesIndex(r.html)[0]?.dateText ?? null : null }) }); if (!index.success || !index.html) { ctx.anomaly('page_fetch_failed', `/auctions: ${index.error ?? index.httpStatus}`); return; } const sales = parseSalesIndex(index.html).filter((s) => s.hasPrices); if (!sales.length) { ctx.anomaly('selector_missing', '/auctions: no past-sale rows with prices realised'); return; } const queue = sales.filter((s) => !done.has(s.number)); let processed = 0; let yielded = 0; for (const sale of queue) { if (ctx.signal?.aborted || processed >= salesPerRun || this.reached(ctx, yielded)) break; const url = `${SITE}/auctions/sale/${sale.number}/prices-realised`; await this.throttle(); const res = await ctx.fetch(url, { engines: ['api'], responseType: 'text', expect: ['title', 'price'], parse: (r) => { const p = r.html ? parsePricesRealised(r.html, sale.number) : null; const sold = p?.lots.find((l) => l.priceText); return p ? { title: p.sale.title, price: sold?.priceText ?? null } : null; }, }); processed++; if (!res.success || !res.html) { ctx.anomaly('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); continue; } const page = parsePricesRealised(res.html, sale.number); if (!page || !page.lots.length) { ctx.anomaly('parse_failure_page', `${url}: no lot rows`); continue; } // the index carries the sale date when the prices page omits it (rare) const saleInfo = { ...page.sale, dateText: page.sale.dateText ?? sale.dateText, city: page.sale.city ?? sale.city }; for (let i = 0; i < page.lots.length; i += CHUNK) { if (ctx.signal?.aborted || this.reached(ctx, yielded)) break; const lots = page.lots.slice(i, i + CHUNK); if (!lots.some((l) => l.priceText)) continue; yielded++; const payload: Payload = { kind: 'prices_realised', sale: saleInfo, url, premiumNote: page.premiumNote, chunk: i / CHUNK, totalLots: page.lots.length, lots }; yield { url, externalId: `sale:${sale.number}:chunk:${i / CHUNK}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload, fetchedAt: res.fetchedAt }; } done.add(sale.number); if (ctx.options.mode === 'backfill') { const idx = sales.findIndex((s) => s.number === sale.number); await ctx.progress({ page: idx + 1, totalPages: sales.length, itemsProcessed: yielded, reachedDate: parseAuctionDate(saleInfo.dateText) }); } await ctx.setCursor({ doneSales: [...done].slice(-500), updatedAt: new Date().toISOString() }); } if (ctx.options.mode === 'backfill' && sales.every((s) => done.has(s.number))) await ctx.setCursor({ doneSales: [...done].slice(-500), done: true, updatedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const saleDate = parseAuctionDate(p.sale.dateText); if (!saleDate) return []; const premiumExcluded = p.premiumNote ? /exclude.*premium/i.test(p.premiumNote) : null; const out: NormalizedSale[] = []; for (const lot of p.lots) { const price = realized(lot.priceText, 'AUD'); if (!price) continue; // Passed in / withdrawn const categorySlug = numisCategory(lot.description, p.sale.title, 'coins'); const g = parseCoinGrade(lot.description); const attributes = numisAttributes({ categorySlug, title: lot.description, section: p.sale.title, country: undefined, identifiers: { noble_lot: lot.lotId }, metadata: { sale_number: p.sale.number, sale_title: p.sale.title, city: p.sale.city, headline: lot.headline, grading_text: lot.gradingText, hammer_price: price.amount, buyer_premium: p.premiumNote ?? 'not stated' }, }); const sale = makeSale({ meta: this.meta, sourceUrl: `${SITE}/auctions/lot/?id=${lot.lotId}`, externalId: lot.lotId, rawTitle: lot.description.length > 240 ? `${lot.description.slice(0, 239)}…` : lot.description, description: lot.description, attributes, price: price.amount, currency: 'AUD', saleDate, buyerPremiumIncluded: premiumExcluded === null ? null : !premiumExcluded, auctionHouse: 'Noble Numismatics', lotNumber: lot.lotNumber, observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, confidence: g.grader ? 0.85 : 0.78, isBundle: isNumisBundle(lot.description), conditionRaw: lot.gradingText?.replace(/\.\s*$/, '') ?? g.conditionRaw, location: 'AU', }); sale.grade = { grader: g.grader, grade: g.grade, qualifier: g.qualifier, certificationNumber: g.certificationNumber }; out.push(sale); } return out; } } export default (meta: ConnectorMeta) => new NobleNumismaticsConnector(meta);