import { parsePrice, type AssetAttributes, type CurrencyCode, type NormalizedSale } from '@rareindex/shared'; import type { ConnectorMeta } from '@rareindex/connectors'; /** * Helpers shared by the auction-house connectors (cars, memorabilia, books, comics). * Kept outside the framework: source-specific vocabulary lives here, canonical models stay clean. */ /** Multi-word makes first so "Land Rover" wins over "Land". `moto` marks motorcycle makes. */ const MAKES: Array<{ name: string; moto?: boolean; aliases?: string[] }> = [ { name: 'Alfa Romeo' }, { name: 'Aston Martin' }, { name: 'Land Rover' }, { name: 'Range Rover' }, { name: 'Mercedes-Benz', aliases: ['Mercedes Benz', 'Mercedes'] }, { name: 'Rolls-Royce', aliases: ['Rolls Royce'] }, { name: 'De Tomaso' }, { name: 'American Motors', aliases: ['AMC'] }, { name: 'Austin-Healey', aliases: ['Austin Healey'] }, { name: 'Harley-Davidson', moto: true, aliases: ['Harley Davidson'] }, { name: 'Moto Guzzi', moto: true }, { name: 'MV Agusta', moto: true }, { name: 'Royal Enfield', moto: true }, { name: 'International Harvester', aliases: ['International'] }, { name: 'Willys-Overland', aliases: ['Willys'] }, { name: 'Pierce-Arrow' }, { name: 'Duesenberg' }, { name: 'Bugatti' }, { name: 'Ferrari' }, { name: 'Porsche' }, { name: 'Lamborghini' }, { name: 'McLaren' }, { name: 'Pagani' }, { name: 'Koenigsegg' }, { name: 'Maserati' }, { name: 'Lancia' }, { name: 'Fiat' }, { name: 'Lotus' }, { name: 'Jaguar' }, { name: 'Bentley' }, { name: 'MG' }, { name: 'Triumph' }, { name: 'Mini' }, { name: 'BMW' }, { name: 'Audi' }, { name: 'Volkswagen', aliases: ['VW'] }, { name: 'Ford' }, { name: 'Chevrolet', aliases: ['Chevy'] }, { name: 'Dodge' }, { name: 'Plymouth' }, { name: 'Chrysler' }, { name: 'Pontiac' }, { name: 'Buick' }, { name: 'Cadillac' }, { name: 'Oldsmobile' }, { name: 'GMC' }, { name: 'Lincoln' }, { name: 'Mercury' }, { name: 'Jeep' }, { name: 'Shelby' }, { name: 'Tesla' }, { name: 'Toyota' }, { name: 'Lexus' }, { name: 'Honda' }, { name: 'Acura' }, { name: 'Nissan' }, { name: 'Datsun' }, { name: 'Infiniti' }, { name: 'Mazda' }, { name: 'Subaru' }, { name: 'Mitsubishi' }, { name: 'Suzuki' }, { name: 'Isuzu' }, { name: 'Hyundai' }, { name: 'Kia' }, { name: 'Genesis' }, { name: 'Volvo' }, { name: 'Saab' }, { name: 'Peugeot' }, { name: 'Citroën', aliases: ['Citroen'] }, { name: 'Renault' }, { name: 'Alpine' }, { name: 'Opel' }, { name: 'Skoda' }, { name: 'Morgan' }, { name: 'TVR' }, { name: 'Caterham' }, { name: 'Noble' }, { name: 'Rover' }, { name: 'Sunbeam' }, { name: 'Hillman' }, { name: 'Vauxhall' }, { name: 'Packard' }, { name: 'Studebaker' }, { name: 'Hudson' }, { name: 'Nash' }, { name: 'DeLorean' }, { name: 'Saturn' }, { name: 'Hummer' }, { name: 'Ram' }, { name: 'Rivian' }, { name: 'Lucid' }, { name: 'Ducati', moto: true }, { name: 'Kawasaki', moto: true }, { name: 'Yamaha', moto: true }, { name: 'Indian', moto: true }, { name: 'Norton', moto: true }, { name: 'BSA', moto: true }, { name: 'Vespa', moto: true }, { name: 'Aprilia', moto: true }, { name: 'KTM', moto: true }, { name: 'Husqvarna', moto: true }, { name: 'Vincent', moto: true }, { name: 'Brough Superior', moto: true }, { name: 'Zero', moto: true }, ]; const MOTO_HINTS = /\b(motorcycle|motorbike|scooter|sidecar|caf[eé] racer|superbike|\d{2,4}cc)\b/i; export interface ParsedVehicleTitle { year: number | null; make: string | null; model: string | null; rest: string | null; name: string; categorySlug: 'automobiles' | 'motorcycles'; } /** "2004 GMC Sierra 2500HD SLT Crew Cab 4×4 Duramax" → year 2004, make GMC, model "Sierra 2500HD", rest, category. */ export function parseVehicleTitle(title: string, hint: { moto?: boolean } = {}): ParsedVehicleTitle { const t = title.replace(/\s+/g, ' ').trim(); const ym = t.match(/^(?:c\.?\s*|circa\s+)?((?:18|19|20)\d{2})(?:\s*[-–/]\s*(?:\d{2,4}))?\b\s*/); const year = ym ? Number(ym[1]) : null; let body = ym ? t.slice(ym[0].length) : t; body = body.replace(/^(?:No Reserve:|Modified|Original-Owner|One-Owner|\d+k?-Mile)\s+/i, ''); let make: string | null = null; let moto = Boolean(hint.moto); for (const m of MAKES) { const names = [m.name, ...(m.aliases ?? [])]; const hit = names.find((n) => new RegExp(`^${n.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\\b`, 'i').test(body)); if (hit) { make = m.name; body = body.slice(hit.length).trim(); if (m.moto) moto = true; break; } } if (!make) { const first = body.split(' ')[0] ?? null; make = first && /^[A-Z][A-Za-z-]+$/.test(first) ? first : null; if (make) body = body.slice(make.length).trim(); } if (MOTO_HINTS.test(t)) moto = true; const tokens = body.split(' ').filter(Boolean); const model = tokens.length ? tokens.slice(0, Math.min(2, tokens.length)).join(' ') : null; const rest = tokens.length > 2 ? tokens.slice(2).join(' ') : null; return { year, make, model, rest, name: ym ? t.slice(ym[0].length).trim() || t : t, categorySlug: moto ? 'motorcycles' : 'automobiles' }; } export function vehicleAttributes(title: string, extra: Partial & { identifiers?: Record; metadata?: Record; moto?: boolean } = {}): AssetAttributes { const v = parseVehicleTitle(title, { moto: extra.moto }); return { categorySlug: extra.categorySlug ?? v.categorySlug, subcategorySlug: null, franchise: null, brand: v.make, series: null, set: null, setCode: null, name: v.name, model: v.model, reference: null, number: null, year: v.year, edition: null, variant: v.rest, language: null, region: extra.region ?? null, country: extra.country ?? null, material: null, size: null, color: null, rarity: null, productionQuantity: null, originalMsrp: null, originalMsrpCurrency: null, identifiers: extra.identifiers ?? {}, metadata: extra.metadata ?? {}, }; } /** Generic attributes for non-vehicle lots (memorabilia, books, comics…) with a taxonomy slug supplied by the connector. */ export function lotAttributes(input: { categorySlug: string; name: string; brand?: string | null; series?: string | null; set?: string | null; number?: string | null; year?: number | null; variant?: string | null; country?: string | null; identifiers?: Record; metadata?: Record }): AssetAttributes { return { categorySlug: input.categorySlug, subcategorySlug: null, franchise: null, brand: input.brand ?? null, series: input.series ?? null, set: input.set ?? null, setCode: null, name: input.name, model: null, reference: null, number: input.number ?? null, year: input.year ?? null, edition: null, variant: input.variant ?? null, language: null, region: null, country: input.country ?? null, material: null, size: null, color: null, rarity: null, productionQuantity: null, originalMsrp: null, originalMsrpCurrency: null, identifiers: input.identifiers ?? {}, metadata: input.metadata ?? {}, }; } /** Money helper returning null unless a positive amount with a currency was found. */ export function money(text: string | null | undefined, fallback?: CurrencyCode): { amount: number; currency: CurrencyCode } | null { const p = parsePrice(text, fallback); if (!p || !p.currency || p.amount <= 0) return null; return { amount: p.amount, currency: p.currency }; } /** m/d/yy or m/d/yyyy → UTC midnight. */ export function dateMDY(s: string | null | undefined): Date | null { const m = s?.match(/(\d{1,2})\/(\d{1,2})\/(\d{2,4})/); if (!m) return null; const y = m[3]!.length === 2 ? 2000 + Number(m[3]) : Number(m[3]); return new Date(Date.UTC(y, Number(m[1]) - 1, Number(m[2]))); } /** dd/mm/yyyy → UTC midnight. */ export function dateDMY(s: string | null | undefined): Date | null { const m = s?.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/); if (!m) return null; return new Date(Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1]))); } const MONTHS: Record = { january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, july: 6, august: 7, september: 8, october: 9, november: 10, december: 11, jan: 0, feb: 1, mar: 2, apr: 3, jun: 5, jul: 6, aug: 7, sep: 8, sept: 8, oct: 9, nov: 10, dec: 11 }; /** "October 16, 2025" | "16 August 2025" | "August 14-16" (+year) → UTC date of the FIRST day; range kept by caller. */ export function dateWords(s: string | null | undefined, yearHint?: number | null): Date | null { if (!s) return null; let m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:\s*[-–]\s*\d{1,2})?,?\s+(\d{4})/); if (m && MONTHS[m[1]!.toLowerCase()] !== undefined) return new Date(Date.UTC(Number(m[3]), MONTHS[m[1]!.toLowerCase()]!, Number(m[2]))); m = s.match(/(\d{1,2})(?:\s*[-–]\s*\d{1,2})?\s+([A-Za-z]{3,9})\.?,?\s+(\d{4})/); if (m && MONTHS[m[2]!.toLowerCase()] !== undefined) return new Date(Date.UTC(Number(m[3]), MONTHS[m[2]!.toLowerCase()]!, Number(m[1]))); if (yearHint) { m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2})/); if (m && MONTHS[m[1]!.toLowerCase()] !== undefined) return new Date(Date.UTC(yearHint, MONTHS[m[1]!.toLowerCase()]!, Number(m[2]))); } return null; } export interface SaleInput { meta: ConnectorMeta; sourceUrl: string; externalId: string; rawTitle: string; attributes: AssetAttributes; price: number; currency: CurrencyCode; saleDate: Date; buyerPremiumIncluded: boolean | null; auctionHouse: string; lotNumber?: string | null; imageUrls?: string[]; description?: string | null; location?: string | null; observedAt: Date; confidence?: number; parserVersion: string; grader?: string | null; grade?: string | null; condition?: string | null; conditionRaw?: string | null; isBundle?: boolean; saleType?: NormalizedSale['saleType']; } export function makeSale(i: SaleInput): NormalizedSale { return { kind: 'sale', connectorId: i.meta.id, sourceId: i.meta.sourceId, sourceUrl: i.sourceUrl, externalId: i.externalId, rawTitle: i.rawTitle, description: i.description ?? null, imageUrls: i.imageUrls ?? [], attributes: i.attributes, grade: { grader: i.grader ?? null, grade: i.grade ?? null, qualifier: null, certificationNumber: null }, condition: { condition: i.condition ?? null, conditionRaw: i.conditionRaw ?? null, completeness: null }, observedAt: i.observedAt, confidence: i.confidence ?? 0.9, parserVersion: i.parserVersion, saleType: i.saleType ?? 'auction', saleDate: i.saleDate, price: i.price, currency: i.currency, buyerPremiumIncluded: i.buyerPremiumIncluded, quantity: 1, isBundle: i.isBundle ?? false, location: i.location ?? null, auctionHouse: i.auctionHouse, lotNumber: i.lotNumber ?? null, }; } /** Split a Firecrawl markdown list into item chunks that each contain a link matching `linkRe`. */ export function splitMarkdownItems(md: string, startRe: RegExp): string[] { const idx: number[] = []; const re = new RegExp(startRe.source, startRe.flags.includes('g') ? startRe.flags : `${startRe.flags}g`); let m: RegExpExecArray | null; while ((m = re.exec(md))) idx.push(m.index); return idx.map((s, k) => md.slice(s, idx[k + 1] ?? md.length)); } export function firstMatch(text: string, re: RegExp): string | null { const m = text.match(re); return m ? (m[1] ?? m[0]) : null; } export const md = { /** first markdown link matching an href regex → { text, href } */ link(text: string, hrefRe: RegExp): { text: string; href: string } | null { const re = new RegExp(`\\[([^\\]]*)\\]\\((${hrefRe.source})(?:\\s+"[^"]*")?\\)`, hrefRe.flags.replace('g', '')); const m = text.match(re); return m ? { text: m[1]!.replace(/\\/g, '').trim(), href: m[2]! } : null; }, image(text: string): string | null { const m = text.match(/!\[[^\]]*\]\((https?:[^)\s]+)\)/); return m ? m[1]! : null; }, clean(s: string): string { return s.replace(/\\([\\*_#.[\]()\-+|])/g, '$1').replace(/\*\*/g, '').replace(/\s+/g, ' ').trim(); }, };