TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { CrawlContext } from '@rareindex/connectors';2import type { ExtractionResult } from '@rareindex/shared';34/** Helpers shared by the dealer/marketplace connectors of this wave (instruments, audio, cameras, books, minerals). */56/** "$1,79995" (B&H used price with superscript cents flattened) → 1799.95 ; "$499.00" → 499 ; "€850" → 850 */7export function compactMoney(s: string | null | undefined): number | null {8 if (!s) return null;9 const t = s.replace(/\s+/g, '');10 if (/\d\.\d{2}$/.test(t)) {11 const n = Number(t.replace(/[^0-9.]/g, ''));12 return Number.isFinite(n) && n > 0 ? n : null;13 }14 const digits = t.replace(/[^0-9]/g, '');15 if (!digits) return null;16 const n = Number(digits);17 return Number.isFinite(n) && n > 0 ? n : null;18}1920/** Same as compactMoney but treats the last two digits as cents when no decimal point is present ("$37895" → 378.95). */21export function compactMoneyCents(s: string | null | undefined): number | null {22 if (!s) return null;23 const t = s.replace(/\s+/g, '');24 if (/\d\.\d{2}$/.test(t)) return compactMoney(t);25 const digits = t.replace(/[^0-9]/g, '');26 if (digits.length < 3) return compactMoney(t);27 const n = Number(`${digits.slice(0, -2)}.${digits.slice(-2)}`);28 return Number.isFinite(n) && n > 0 ? n : null;29}3031const KNOWN_BRANDS = ['Canon', 'Nikon', 'Sony', 'Fujifilm', 'FUJIFILM', 'Leica', 'Hasselblad', 'Panasonic', 'Olympus', 'OM System', 'Pentax', 'Ricoh', 'Sigma', 'Tamron', 'Zeiss', 'Mamiya', 'Phase One', 'Contax', 'Rollei', 'Rolleiflex', 'Voigtlander', 'Voigtländer', 'Blackmagic Design', 'Blackmagic', 'GoPro', 'DJI', 'Kodak', 'Minolta', 'Bronica', 'Polaroid', 'Lomography', 'Marantz', 'McIntosh', 'Technics', 'Nakamichi', 'JBL', 'Klipsch', 'Sansui', 'Pioneer', 'Revox', 'Sennheiser', 'Thorens', 'Linn', 'Tannoy', 'Yamaha', 'Denon', 'Luxman', 'Accuphase', 'Bang & Olufsen', 'Quad', 'Naim', 'Rega', 'Fender', 'Gibson', 'Martin', 'Gretsch', 'Rickenbacker', 'PRS', 'Moog', 'Roland', 'Korg', 'Sequential', 'Oberheim', 'Marshall', 'Vox', 'Mesa/Boogie', 'Dumble', 'Klon', 'Ibanez', 'Taylor', 'Epiphone', 'Music Man', 'Hofner', 'Höfner', 'Ludwig', 'Gretsch', 'Zildjian', 'Neumann', 'Yamaha', 'Hammond', 'Wurlitzer', 'Rhodes', 'Steinway', 'Selmer', 'Bach', 'Buffet'];3233/** Brand from the beginning of a product name (multi-word brands first), else the first word. */34export function brandFromName(name: string): string | null {35 const n = name.trim();36 for (const b of [...KNOWN_BRANDS].sort((a, c) => c.length - a.length)) {37 if (n.toLowerCase().startsWith(b.toLowerCase() + ' ')) return b === 'FUJIFILM' ? 'Fujifilm' : b;38 }39 const first = n.split(/\s+/)[0] ?? '';40 return /^[A-Za-z][A-Za-z&.'-]*$/.test(first) ? first : null;41}4243export function stripBrand(name: string, brand: string | null): string {44 if (!brand) return name.trim();45 return name.trim().replace(new RegExp(`^${brand.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+`, 'i'), '').trim() || name.trim();46}4748/** Fetch a page through Firecrawl (markdown + rawHtml). Quality is judged by the caller's parser. */49export async function fetchFirecrawl(ctx: CrawlContext, url: string, opts: { waitForMs?: number; timeoutMs?: number; parse?: (r: ExtractionResult) => number } = {}): Promise<ExtractionResult> {50 return ctx.fetch(url, {51 engines: ['firecrawl'],52 minQuality: 0.2,53 waitForMs: opts.waitForMs,54 timeoutMs: opts.timeoutMs,55 ...(opts.parse ? { expect: ['title', 'price'], parse: (r) => (opts.parse!(r) > 0 ? { title: 'ok', price: 1 } : null) } : {}),56 });57}5859export function absUrl(base: string, href: string | null | undefined): string | null {60 if (!href) return null;61 try {62 return new URL(href, base).toString();63 } catch {64 return null;65 }66}6768/** Decode a handful of HTML entities that appear in JSON feeds and markdown. */69export function decodeEntities(s: string): string {70 return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ').replace(/\s+/g, ' ').trim();71}72