TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { parsePrice, type AssetAttributes, type CurrencyCode, type NormalizedSale } from '@rareindex/shared';2import type { ConnectorMeta } from '@rareindex/connectors';34/**5 * Helpers shared by the auction-house connectors (cars, memorabilia, books, comics).6 * Kept outside the framework: source-specific vocabulary lives here, canonical models stay clean.7 */89/** Multi-word makes first so "Land Rover" wins over "Land". `moto` marks motorcycle makes. */10const MAKES: Array<{ name: string; moto?: boolean; aliases?: string[] }> = [11 { 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'] },12 { 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 },13 { name: 'MV Agusta', moto: true }, { name: 'Royal Enfield', moto: true }, { name: 'International Harvester', aliases: ['International'] }, { name: 'Willys-Overland', aliases: ['Willys'] }, { name: 'Pierce-Arrow' },14 { 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' },15 { 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' },16 { 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' },17 { 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' },18 { 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' },19 { 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 },20];2122const MOTO_HINTS = /\b(motorcycle|motorbike|scooter|sidecar|caf[eé] racer|superbike|\d{2,4}cc)\b/i;2324export interface ParsedVehicleTitle {25 year: number | null;26 make: string | null;27 model: string | null;28 rest: string | null;29 name: string;30 categorySlug: 'automobiles' | 'motorcycles';31}3233/** "2004 GMC Sierra 2500HD SLT Crew Cab 4×4 Duramax" → year 2004, make GMC, model "Sierra 2500HD", rest, category. */34export function parseVehicleTitle(title: string, hint: { moto?: boolean } = {}): ParsedVehicleTitle {35 const t = title.replace(/\s+/g, ' ').trim();36 const ym = t.match(/^(?:c\.?\s*|circa\s+)?((?:18|19|20)\d{2})(?:\s*[-–/]\s*(?:\d{2,4}))?\b\s*/);37 const year = ym ? Number(ym[1]) : null;38 let body = ym ? t.slice(ym[0].length) : t;39 body = body.replace(/^(?:No Reserve:|Modified|Original-Owner|One-Owner|\d+k?-Mile)\s+/i, '');40 let make: string | null = null;41 let moto = Boolean(hint.moto);42 for (const m of MAKES) {43 const names = [m.name, ...(m.aliases ?? [])];44 const hit = names.find((n) => new RegExp(`^${n.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\\b`, 'i').test(body));45 if (hit) {46 make = m.name;47 body = body.slice(hit.length).trim();48 if (m.moto) moto = true;49 break;50 }51 }52 if (!make) {53 const first = body.split(' ')[0] ?? null;54 make = first && /^[A-Z][A-Za-z-]+$/.test(first) ? first : null;55 if (make) body = body.slice(make.length).trim();56 }57 if (MOTO_HINTS.test(t)) moto = true;58 const tokens = body.split(' ').filter(Boolean);59 const model = tokens.length ? tokens.slice(0, Math.min(2, tokens.length)).join(' ') : null;60 const rest = tokens.length > 2 ? tokens.slice(2).join(' ') : null;61 return { year, make, model, rest, name: ym ? t.slice(ym[0].length).trim() || t : t, categorySlug: moto ? 'motorcycles' : 'automobiles' };62}6364export function vehicleAttributes(title: string, extra: Partial<AssetAttributes> & { identifiers?: Record<string, string>; metadata?: Record<string, unknown>; moto?: boolean } = {}): AssetAttributes {65 const v = parseVehicleTitle(title, { moto: extra.moto });66 return {67 categorySlug: extra.categorySlug ?? v.categorySlug,68 subcategorySlug: null,69 franchise: null,70 brand: v.make,71 series: null,72 set: null,73 setCode: null,74 name: v.name,75 model: v.model,76 reference: null,77 number: null,78 year: v.year,79 edition: null,80 variant: v.rest,81 language: null,82 region: extra.region ?? null,83 country: extra.country ?? null,84 material: null,85 size: null,86 color: null,87 rarity: null,88 productionQuantity: null,89 originalMsrp: null,90 originalMsrpCurrency: null,91 identifiers: extra.identifiers ?? {},92 metadata: extra.metadata ?? {},93 };94}9596/** Generic attributes for non-vehicle lots (memorabilia, books, comics…) with a taxonomy slug supplied by the connector. */97export 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<string, string>; metadata?: Record<string, unknown> }): AssetAttributes {98 return {99 categorySlug: input.categorySlug,100 subcategorySlug: null,101 franchise: null,102 brand: input.brand ?? null,103 series: input.series ?? null,104 set: input.set ?? null,105 setCode: null,106 name: input.name,107 model: null,108 reference: null,109 number: input.number ?? null,110 year: input.year ?? null,111 edition: null,112 variant: input.variant ?? null,113 language: null,114 region: null,115 country: input.country ?? null,116 material: null,117 size: null,118 color: null,119 rarity: null,120 productionQuantity: null,121 originalMsrp: null,122 originalMsrpCurrency: null,123 identifiers: input.identifiers ?? {},124 metadata: input.metadata ?? {},125 };126}127128/** Money helper returning null unless a positive amount with a currency was found. */129export function money(text: string | null | undefined, fallback?: CurrencyCode): { amount: number; currency: CurrencyCode } | null {130 const p = parsePrice(text, fallback);131 if (!p || !p.currency || p.amount <= 0) return null;132 return { amount: p.amount, currency: p.currency };133}134135/** m/d/yy or m/d/yyyy → UTC midnight. */136export function dateMDY(s: string | null | undefined): Date | null {137 const m = s?.match(/(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);138 if (!m) return null;139 const y = m[3]!.length === 2 ? 2000 + Number(m[3]) : Number(m[3]);140 return new Date(Date.UTC(y, Number(m[1]) - 1, Number(m[2])));141}142/** dd/mm/yyyy → UTC midnight. */143export function dateDMY(s: string | null | undefined): Date | null {144 const m = s?.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/);145 if (!m) return null;146 return new Date(Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1])));147}148const MONTHS: Record<string, number> = { 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 };149/** "October 16, 2025" | "16 August 2025" | "August 14-16" (+year) → UTC date of the FIRST day; range kept by caller. */150export function dateWords(s: string | null | undefined, yearHint?: number | null): Date | null {151 if (!s) return null;152 let m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:\s*[-–]\s*\d{1,2})?,?\s+(\d{4})/);153 if (m && MONTHS[m[1]!.toLowerCase()] !== undefined) return new Date(Date.UTC(Number(m[3]), MONTHS[m[1]!.toLowerCase()]!, Number(m[2])));154 m = s.match(/(\d{1,2})(?:\s*[-–]\s*\d{1,2})?\s+([A-Za-z]{3,9})\.?,?\s+(\d{4})/);155 if (m && MONTHS[m[2]!.toLowerCase()] !== undefined) return new Date(Date.UTC(Number(m[3]), MONTHS[m[2]!.toLowerCase()]!, Number(m[1])));156 if (yearHint) {157 m = s.match(/([A-Za-z]{3,9})\.?\s+(\d{1,2})/);158 if (m && MONTHS[m[1]!.toLowerCase()] !== undefined) return new Date(Date.UTC(yearHint, MONTHS[m[1]!.toLowerCase()]!, Number(m[2])));159 }160 return null;161}162163export interface SaleInput {164 meta: ConnectorMeta;165 sourceUrl: string;166 externalId: string;167 rawTitle: string;168 attributes: AssetAttributes;169 price: number;170 currency: CurrencyCode;171 saleDate: Date;172 buyerPremiumIncluded: boolean | null;173 auctionHouse: string;174 lotNumber?: string | null;175 imageUrls?: string[];176 description?: string | null;177 location?: string | null;178 observedAt: Date;179 confidence?: number;180 parserVersion: string;181 grader?: string | null;182 grade?: string | null;183 condition?: string | null;184 conditionRaw?: string | null;185 isBundle?: boolean;186 saleType?: NormalizedSale['saleType'];187}188189export function makeSale(i: SaleInput): NormalizedSale {190 return {191 kind: 'sale',192 connectorId: i.meta.id,193 sourceId: i.meta.sourceId,194 sourceUrl: i.sourceUrl,195 externalId: i.externalId,196 rawTitle: i.rawTitle,197 description: i.description ?? null,198 imageUrls: i.imageUrls ?? [],199 attributes: i.attributes,200 grade: { grader: i.grader ?? null, grade: i.grade ?? null, qualifier: null, certificationNumber: null },201 condition: { condition: i.condition ?? null, conditionRaw: i.conditionRaw ?? null, completeness: null },202 observedAt: i.observedAt,203 confidence: i.confidence ?? 0.9,204 parserVersion: i.parserVersion,205 saleType: i.saleType ?? 'auction',206 saleDate: i.saleDate,207 price: i.price,208 currency: i.currency,209 buyerPremiumIncluded: i.buyerPremiumIncluded,210 quantity: 1,211 isBundle: i.isBundle ?? false,212 location: i.location ?? null,213 auctionHouse: i.auctionHouse,214 lotNumber: i.lotNumber ?? null,215 };216}217218/** Split a Firecrawl markdown list into item chunks that each contain a link matching `linkRe`. */219export function splitMarkdownItems(md: string, startRe: RegExp): string[] {220 const idx: number[] = [];221 const re = new RegExp(startRe.source, startRe.flags.includes('g') ? startRe.flags : `${startRe.flags}g`);222 let m: RegExpExecArray | null;223 while ((m = re.exec(md))) idx.push(m.index);224 return idx.map((s, k) => md.slice(s, idx[k + 1] ?? md.length));225}226227export function firstMatch(text: string, re: RegExp): string | null {228 const m = text.match(re);229 return m ? (m[1] ?? m[0]) : null;230}231232export const md = {233 /** first markdown link matching an href regex → { text, href } */234 link(text: string, hrefRe: RegExp): { text: string; href: string } | null {235 const re = new RegExp(`\\[([^\\]]*)\\]\\((${hrefRe.source})(?:\\s+"[^"]*")?\\)`, hrefRe.flags.replace('g', ''));236 const m = text.match(re);237 return m ? { text: m[1]!.replace(/\\/g, '').trim(), href: m[2]! } : null;238 },239 image(text: string): string | null {240 const m = text.match(/!\[[^\]]*\]\((https?:[^)\s]+)\)/);241 return m ? m[1]! : null;242 },243 clean(s: string): string {244 return s.replace(/\\([\\*_#.[\]()\-+|])/g, '$1').replace(/\*\*/g, '').replace(/\s+/g, ' ').trim();245 },246};247