SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
11.9 KB · 247 lines typescript
Raw Blame History
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