import { z } from 'zod'; import { BaseConnector, html as H, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import { AssetAttributesSchema, NormalizedSaleSchema, type NormalizedRecord } from '@rareindex/shared'; import { whiskyFacts } from '../../api/scotch-whisky-auctions/index.js'; import { dateDMY } from '../_carlib/index.js'; /** * Whisky Hammer previous auctions. Auction list is plain HTML; lot pages come through Firecrawl markdown. * One raw record per lot page (compact lots); one sale per lot with a "Sold dd/mm/yyyy £X" line. */ const BASE = 'https://www.whiskyhammer.com'; const PARSER_VERSION = '1.0.0'; export const LotSchema = z.object({ itemId: z.string(), url: z.string(), title: z.string(), soldOn: z.string().nullable(), priceGbp: z.number().nullable(), image: z.string().nullable(), warehouse: z.string().nullable() }); export type Lot = z.infer; export const PagePayloadSchema = z.object({ kind: z.literal('lot_page'), url: z.string(), auctionId: z.string(), page: z.number(), totalPages: z.number().nullable(), totalItems: z.number().nullable(), lots: z.array(LotSchema) }); /** /previous-auctions → auction ids (auc-129 …), newest first as listed. */ export function parseAuctionList(htmlText: string): string[] { const $ = H.load(htmlText); const ids: string[] = []; $('a[href*="/auction/past/auc-"]').each((_, a) => { const id = ($(a).attr('href') ?? '').match(/auc-(\d+)/)?.[1]; if (id && !ids.includes(id)) ids.push(id); }); return ids.sort((a, b) => Number(b) - Number(a)); } /** * Parse a Firecrawl markdown lot page. Blocks look like: * [![Title](img)](/item/239555/…) … Lot #239555 … [Title](/item/239555/…) … Sold 22/02/2026£9,100.00€… … [View Lot](…) */ export function parseLotMarkdown(md: string): { lots: Lot[]; totalPages: number | null; totalItems: number | null } { const lots: Lot[] = []; const seen = new Set(); const totalItems = Number(md.match(/(\d[\d,]*)\s+Items/)?.[1]?.replace(/,/g, '')) || null; const pageNums = [...md.matchAll(/\?page=(\d+)\)/g)].map((m) => Number(m[1])); const totalPages = pageNums.length ? Math.max(...pageNums) : null; const re = /Lot #(\d+)\s*\n+\s*\[([^\]]+)\]\((https:\/\/www\.whiskyhammer\.com\/item\/\1\/[^)\s]+)\)\s*\n+\s*(Sold\s+(\d{2}\/\d{2}\/\d{4})\s*£([\d,]+(?:\.\d+)?))?/g; let m: RegExpExecArray | null; while ((m = re.exec(md))) { const itemId = m[1]!; if (seen.has(itemId)) continue; seen.add(itemId); const before = md.slice(Math.max(0, m.index - 1500), m.index); const img = [...before.matchAll(/!\[[^\]]*\]\((https:\/\/www\.whiskyhammer\.com\/uploads\/images\/products\/[^)\s]+)\)/g)].at(-1)?.[1] ?? null; const warehouse = /EU warehouse/i.test(before.slice(-600)) ? 'NL' : 'GB'; lots.push({ itemId, url: m[3]!, title: m[2]!.replace(/\\/g, '').trim(), soldOn: m[5] ?? null, priceGbp: m[6] ? Number(m[6].replace(/,/g, '')) : null, image: img, warehouse }); } return { lots, totalPages, totalItems }; } export class WhiskyHammerConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 2000; async *crawl(ctx: CrawlContext): AsyncIterable { const perRun = Number(this.meta.config.auctionsPerRun ?? 1); const pagesPerRun = Number(this.meta.config.pagesPerRun ?? 6); const progress = { ...((ctx.options.cursor?.progress as Record | undefined) ?? {}) }; const complete = new Set((ctx.options.cursor?.complete as string[] | undefined) ?? []); const list = await ctx.fetch(`${BASE}/previous-auctions`, { engines: ['api', 'firecrawl'], responseType: 'text', minQuality: 0 }); const ids = list.success && list.html ? parseAuctionList(list.html) : []; if (!ids.length) { ctx.anomaly('page_fetch_failed', `auction list: ${list.error ?? list.httpStatus}`); return; } // The newest entry can still be closing (page not yet published); tolerate a few unreachable auctions per run. const candidates = ids.filter((id) => !complete.has(id)).slice(0, perRun + 3); let pages = 0; let count = 0; let started = 0; for (const auctionId of candidates) { if (started >= perRun) break; let page = (progress[auctionId] ?? 0) + 1; while (pages < pagesPerRun && !ctx.signal?.aborted && !this.reached(ctx, count)) { const url = `${BASE}/auction/past/auc-${auctionId}/${page > 1 ? `?page=${page}` : ''}`; await this.throttle(); const res = await ctx.fetch(url, { engines: ['firecrawl'], timeoutMs: 150_000, expect: ['title', 'price', 'currency', 'date'], parse: (r) => { const p = r.markdown ? parseLotMarkdown(r.markdown) : null; const sold = p?.lots.find((l) => l.priceGbp); return p && p.lots.length ? { title: p.lots[0]!.title, price: sold?.priceGbp ?? null, currency: sold ? 'GBP' : null, date: sold?.soldOn ?? null } : null; }, }); pages++; const parsed = res.success && res.markdown ? parseLotMarkdown(res.markdown) : null; if (!parsed || !parsed.lots.length) { ctx.anomaly(parsed ? 'empty_page' : 'page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); if (page > 1 || parsed) complete.add(auctionId); // unreachable first page: retry next run break; } if (page === 1) started++; count++; yield { url, externalId: `auction:${auctionId}:page:${page}`, kind: 'sale', engine: res.engine, httpStatus: res.httpStatus, payload: { kind: 'lot_page' as const, url, auctionId, page, totalPages: parsed.totalPages, totalItems: parsed.totalItems, lots: parsed.lots }, fetchedAt: res.fetchedAt }; progress[auctionId] = page; if (parsed.totalPages && page >= parsed.totalPages) { complete.add(auctionId); break; } page++; } await ctx.setCursor({ progress, complete: [...complete] }); } } async normalize(raw: RawRecordLike): Promise { const p = PagePayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; for (const lot of p.lots) { const saleDate = dateDMY(lot.soldOn); if (!lot.priceGbp || lot.priceGbp <= 0 || !saleDate) continue; const f = whiskyFacts(lot.title.replace(/\s+-\s+/, ' ')); const attributes = AssetAttributesSchema.parse({ categorySlug: f.categorySlug, brand: lot.title.split(/\s+-\s+/)[0]?.trim() || f.brand, name: lot.title, year: f.vintage, size: f.size, country: /scotch|islay|speyside|highland|campbeltown|lowland/i.test(lot.title) ? 'GB' : null, identifiers: { whiskyhammer_item: lot.itemId }, metadata: { age_statement: f.age, auction_id: p.auctionId, warehouse: lot.warehouse }, }); out.push( NormalizedSaleSchema.parse({ kind: 'sale', connectorId: this.meta.id, sourceId: this.meta.sourceId, sourceUrl: lot.url, externalId: lot.itemId, rawTitle: lot.title, imageUrls: lot.image ? [lot.image] : [], attributes, grade: { grader: null, grade: null, qualifier: null, certificationNumber: null }, condition: { condition: null, conditionRaw: null, completeness: null }, observedAt: raw.fetchedAt, confidence: 0.9, parserVersion: PARSER_VERSION, saleType: 'auction', saleDate, price: lot.priceGbp, currency: 'GBP', buyerPremiumIncluded: false, quantity: 1, isBundle: /\bx\s?\d|\(\d+\s?x\)|\bset of\b|\blot of\b/i.test(lot.title), location: lot.warehouse === 'NL' ? 'Alphen aan den Rijn, Netherlands' : 'Aberdeenshire, United Kingdom', auctionHouse: 'Whisky Hammer', lotNumber: lot.itemId, }), ); } return out; } } export default function createConnector(meta: ConnectorMeta): WhiskyHammerConnector { return new WhiskyHammerConnector(meta); }