import { z } from 'zod'; import { adapters, BaseConnector, type ConnectorMeta, type CrawlContext, type RawRecordInput, type RawRecordLike } from '@rareindex/connectors'; import type { NormalizedRecord } from '@rareindex/shared'; import { slugFromTitle, watchBrand } from '../_auction-lib/categories.js'; import { popCultureCategory } from '../_memorabilia-lib/index.js'; import { amount, houseCategory, isBundleTitle, isCurrency, isoDate, jsonObjectsWithMarker, lotAttributes, makeLot, makeSale, safeYear, saleGrade, sportsCategory } from '../_g7-auctions-na-lib/index.js'; const BASE = 'https://live.millerandmillerauctions.com'; const HOUSE = 'Miller & Miller Auctions'; const PARSER_VERSION = '1.0.0'; export const AuctionSchema = z.object({ id: z.string(), title: z.string(), url: z.string(), timeStart: z.string().nullable(), endTime: z.string().nullable(), auctionType: z.string().nullable(), buyersPremiumPct: z.number().nullable(), currency: z.string().nullable(), lotCount: z.number().nullable() }); export const LotSchema = z.object({ id: z.string(), lotNumber: z.string().nullable(), status: z.string().nullable(), title: z.string(), description: z.string().nullable(), estimateLow: z.number().nullable(), estimateHigh: z.number().nullable(), startingPrice: z.number().nullable(), currency: z.string().nullable(), image: z.string().nullable(), /** winning timed bid (hammer, before buyer's premium) */ hammer: z.number().nullable(), bidAt: z.string().nullable(), lastUpdated: z.string().nullable(), url: z.string(), }); export const PayloadSchema = z.object({ kind: z.literal('am_catalog_page'), house: z.string(), auction: AuctionSchema, page: z.number(), lots: z.array(LotSchema) }); export type Payload = z.infer; type AmAuction = { row_id?: string; title?: string; time_start?: string | null; effective_end_time?: string | null; extended_end_time?: string | null; auction_type?: string | null; default_buyers_premium?: string | number | null; currency_code?: string | null; lot_count?: number | null; _detail_url?: string | null; _slug?: string | null }; type AmLot = { row_id?: string; lot_number?: number | string | null; lot_number_extension?: string | null; status?: string | null; title?: string; truncated_description?: string | null; estimate_low?: string | null; estimate_high?: string | null; starting_price?: string | null; currency_code?: string | null; cover_thumbnail?: string | null; timed_auction_bid?: { amount?: string | null; updated_at?: string | null } | null; last_updated?: string | null; _detail_url?: string | null; _slug?: string | null; auction?: AmAuction | null }; function slugify(s: string): string { return s.toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); } /** Auction Mobility server-rendered catalog page → auction header + lot summaries (36 per page). */ export function parseAmCatalog(html: string, auctionId: string, page: number): Payload | null { const auctions = jsonObjectsWithMarker(html, '"type":"auction"'); const lots = jsonObjectsWithMarker(html, '"type":"auction-lot-summary"'); const a = auctions.find((x) => x.row_id === auctionId) ?? auctions[0] ?? lots[0]?.auction ?? null; if (!a && lots.length === 0) return null; const auction: z.infer = { id: a?.row_id ?? auctionId, title: a?.title ?? '', url: a?._detail_url ? `${BASE}${a._detail_url}` : `${BASE}/auctions/${auctionId}`, timeStart: a?.time_start ?? null, endTime: a?.extended_end_time ?? a?.effective_end_time ?? lots.find((l) => l.auction?.effective_end_time)?.auction?.effective_end_time ?? null, auctionType: a?.auction_type ?? null, buyersPremiumPct: amount(a?.default_buyers_premium ?? lots.find((l) => l.auction?.default_buyers_premium)?.auction?.default_buyers_premium), currency: a?.currency_code ?? lots.find((l) => l.currency_code)?.currency_code ?? null, lotCount: typeof a?.lot_count === 'number' ? a.lot_count : null, }; const seen = new Set(); const items: Payload['lots'] = []; for (const l of lots) { if (!l.row_id || !l.title || seen.has(l.row_id)) continue; seen.add(l.row_id); items.push({ id: l.row_id, lotNumber: l.lot_number !== null && l.lot_number !== undefined ? `${l.lot_number}${l.lot_number_extension ?? ''}` : null, status: l.status ?? null, title: l.title.trim(), description: l.truncated_description?.trim() || null, estimateLow: amount(l.estimate_low), estimateHigh: amount(l.estimate_high), startingPrice: amount(l.starting_price), currency: l.currency_code ?? auction.currency, image: l.cover_thumbnail ?? null, hammer: amount(l.timed_auction_bid?.amount), bidAt: l.timed_auction_bid?.updated_at ?? null, lastUpdated: l.last_updated ?? null, url: l._detail_url ? `${BASE}${l._detail_url}` : `${BASE}/lots/view/${l.row_id}/${slugify(l.title)}`, }); } return { kind: 'am_catalog_page', house: HOUSE, auction, page, lots: items }; } /** Miller & Miller sale titles → taxonomy; the sale department is the strongest signal. */ export function millerCategory(saleTitle: string, lotTitle: string): string | null { const s = saleTitle.toLowerCase(); if (/firearm|weapon|edged/.test(s) || /\b(rifle|pistol|revolver|shotgun|carbine|ammunition|cartridges?)\b/i.test(lotTitle)) return null; if (/sports? card|sports? memorabilia|hockey|baseball/.test(s)) return sportsCategory(lotTitle); if (/petroliana|advertising|soda|breweriana|general store|country store|signs?/.test(s)) return /\b(sign|clock|thermometer|display|calendar|poster|globe|tin\b|can\b|bottle|crate|tray|door push|cabinet|dispenser)\b/i.test(lotTitle) ? 'advertising' : slugFromTitle(lotTitle) ?? 'advertising'; if (/coin[- ]?op|arcade|jukebox|slot/.test(s)) return /\b(slot machine|trade stimulator)\b/i.test(lotTitle) ? 'casino_memorabilia' : /\b(jukebox|arcade|pinball)\b/i.test(lotTitle) ? 'arcade_pinball' : 'vending_machines'; if (/toys?|comics?|pop culture|star wars|movie|music memorabilia/.test(s)) return popCultureCategory(lotTitle); if (/watch|jewel/.test(s)) return /watch|chronograph/i.test(lotTitle) ? watchBrand(lotTitle).slug : slugFromTitle(lotTitle, 'jewelry'); if (/coins?|currency|banknote|numismatic/.test(s)) return /\b(note|bill|banknote|dollar bill)\b/i.test(lotTitle) ? 'banknotes' : 'coins'; if (/folk art|fine art|paintings?|canadian art|inuit|prints/.test(s)) return slugFromTitle(lotTitle, 'art') ?? 'art'; if (/decoy|canadiana|historic|antiques?|furniture|pottery|glass|clocks?|decorative/.test(s)) return slugFromTitle(lotTitle, 'furniture') ?? 'antiques'; if (/music|instrument|guitar/.test(s)) return slugFromTitle(lotTitle, 'music') ?? 'musical_instruments'; if (/militaria|military|medal/.test(s)) return slugFromTitle(lotTitle, 'militaria') ?? 'militaria'; if (/book|map|document|ephemera|postcard/.test(s)) return /\bpostcard/i.test(lotTitle) ? 'postcards' : slugFromTitle(lotTitle, 'books') ?? 'books'; if (/automobil|motorcycle|cars?\b|automotive/.test(s)) return slugFromTitle(lotTitle, 'cars') ?? 'automotive_memorabilia'; return houseCategory(saleTitle, lotTitle, null); } /** * Miller & Miller Auctions (New Hamburg, Ontario) — Auction Mobility platform. The public catalog pages * live.millerandmillerauctions.com/auctions/[/]?page=N embed the lot summaries (36 per page) as * JSON in the server-rendered HTML; past lots carry status "sold" and the winning timed bid (hammer, CAD). * Auction ids come from the platform's own sitemap (/sitemap/auctions.xml). No API calls, no login. */ export class MillerMillerConnector extends BaseConnector { readonly version = '1.0.0'; readonly parserVersion = PARSER_VERSION; protected override minIntervalMs = 10_000; // robots.txt Crawl-delay: 10 private async page(ctx: CrawlContext, auctionId: string, page: number): Promise<{ payload: Payload | null; engine: 'api'; status: number | null; fetchedAt: Date; url: string }> { const url = `${BASE}/auctions/${auctionId}${page > 1 ? `?page=${page}` : ''}`; 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('page_fetch_failed', `${url}: ${res.error ?? res.httpStatus}`); return { payload: null, engine: 'api', status: res.httpStatus, fetchedAt: res.fetchedAt, url }; } const payload = parseAmCatalog(res.html, auctionId, page); if (!payload) ctx.anomaly('parse_failure_page', `${url}: no Auction Mobility JSON found`); return { payload, engine: 'api', status: res.httpStatus, fetchedAt: res.fetchedAt, url }; } async *crawl(ctx: CrawlContext): AsyncIterable { const cfg = this.meta.config; const auctionsPerRun = ctx.options.mode === 'probe' ? 1 : Number(cfg.auctionsPerRun ?? (ctx.options.mode === 'backfill' ? 6 : 3)); const maxPages = ctx.options.mode === 'probe' ? 1 : Number(cfg.pagesPerAuction ?? 40); const cursor = ctx.options.cursor ?? {}; const finished: Record = typeof cursor.finished === 'object' && cursor.finished ? { ...(cursor.finished as Record) } : {}; let ids: string[] = ctx.options.seeds?.length ? ctx.options.seeds.map((s) => s.match(/auctions\/([A-Z0-9-]+)/i)?.[1] ?? s) : []; if (!ids.length) { const entries = await adapters.discoverFromSitemap(ctx, `${BASE}/sitemap/auctions.xml`, { maxSitemaps: 1, match: /sitemap\/auctions\// }); // The index itself lists one child sitemap per auction; we only need the ids (no child fetch). ids = entries.map((e) => e.loc.match(/auctions\/([A-Z0-9-]+)\.xml$/i)?.[1] ?? '').filter(Boolean); if (!ids.length) { const res = await ctx.fetch(`${BASE}/sitemap/auctions.xml`, { engines: ['api'], responseType: 'text', minQuality: 0, force: true }); ids = [...(res.html ?? '').matchAll(/auctions\/([A-Z0-9-]+)\.xml/gi)].map((m) => m[1]!); } if (!ids.length) { ctx.anomaly('pagination_failure', 'auction sitemap returned no auction ids'); return; } ids.reverse(); // newest ids are appended last by the platform } const total = ids.length; const pending = ids.filter((id) => !finished[id]); let processed = 0; let count = 0; let items = 0; for (const id of pending) { if (ctx.signal?.aborted || processed >= auctionsPerRun || this.reached(ctx, count)) break; let complete = true; let endTime: string | null = null; for (let page = 1; page <= maxPages; page++) { if (ctx.signal?.aborted) break; const r = await this.page(ctx, id, page); if (!r.payload) { complete = false; break; } endTime = r.payload.auction.endTime ?? r.payload.auction.timeStart ?? endTime; if (r.payload.lots.length === 0) break; count++; items += r.payload.lots.length; yield { url: r.url, externalId: `auction:${id}:page:${page}`, kind: r.payload.lots.some((l) => l.status === 'sold') ? 'sale' : 'auction_lot', engine: r.engine, httpStatus: r.status, payload: r.payload, fetchedAt: r.fetchedAt }; if (r.payload.lots.length < 36 || this.reached(ctx, count)) break; if (page === maxPages) complete = false; } processed++; // Closed auctions are crawled once; open/upcoming ones are revisited every run until they close. const ended = endTime ? new Date(endTime).getTime() < Date.now() - 2 * 86_400_000 : false; if (complete && ended) finished[id] = endTime ?? new Date().toISOString(); const remaining = ids.filter((x) => !finished[x]).length; await ctx.setCursor({ finished, updatedAt: new Date().toISOString() }); await ctx.progress({ page: total - remaining, totalPages: total, itemsProcessed: items, cursor: { finished } }); } if (ctx.options.mode === 'backfill' && ids.every((id) => finished[id])) await ctx.setCursor({ finished, done: true, updatedAt: new Date().toISOString() }); } async normalize(raw: RawRecordLike): Promise { const p = PayloadSchema.parse(raw.payload); const out: NormalizedRecord[] = []; const auctionEnd = isoDate(p.auction.endTime); const auctionStart = isoDate(p.auction.timeStart); const now = Date.now(); for (const l of p.lots) { const slug = millerCategory(p.auction.title, l.title); if (!slug) continue; const currency = isCurrency(l.currency) ? l.currency : isCurrency(p.auction.currency) ? p.auction.currency : 'CAD'; const g = saleGrade(l.title); const attributes = lotAttributes({ categorySlug: slug, name: l.title, year: safeYear(l.title), identifiers: { miller_lot_id: l.id }, metadata: { auction_id: p.auction.id, auction_title: p.auction.title, auction_type: p.auction.auctionType, estimate_low: l.estimateLow, estimate_high: l.estimateHigh, starting_price: l.startingPrice, buyers_premium_pct: p.auction.buyersPremiumPct, hammer_price: l.hammer } }); const common = { meta: this.meta, sourceUrl: l.url, externalId: l.id, rawTitle: l.title, description: l.description, attributes, imageUrls: l.image ? [l.image] : [], observedAt: raw.fetchedAt, parserVersion: PARSER_VERSION, grader: g.grader, grade: g.grade, location: 'CA' }; if (l.status === 'sold' && l.hammer) { const saleDate = auctionEnd ?? isoDate(l.bidAt) ?? isoDate(l.lastUpdated); if (!saleDate || saleDate.getTime() > now + 86_400_000) continue; out.push(makeSale({ ...common, price: l.hammer, currency, saleDate, buyerPremiumIncluded: false, auctionHouse: HOUSE, lotNumber: l.lotNumber, isBundle: isBundleTitle(l.title), confidence: 0.85 })); } else if (l.status !== 'sold' && l.status !== 'expired' && (!auctionEnd || auctionEnd.getTime() > now)) { out.push(makeLot({ ...common, auctionHouse: HOUSE, auctionName: p.auction.title, lotNumber: l.lotNumber, startsAt: auctionStart, endsAt: auctionEnd, estimateLow: l.estimateLow, estimateHigh: l.estimateHigh, currentBid: l.hammer, currency, status: auctionStart && auctionStart.getTime() > now ? 'upcoming' : 'live', confidence: 0.8 })); } } return out; } } export default (meta: ConnectorMeta) => new MillerMillerConnector(meta);