import type { CrawlContext } from '@rareindex/connectors'; import type { ExtractionResult } from '@rareindex/shared'; /** Helpers shared by the dealer/marketplace connectors of this wave (instruments, audio, cameras, books, minerals). */ /** "$1,79995" (B&H used price with superscript cents flattened) → 1799.95 ; "$499.00" → 499 ; "€850" → 850 */ export function compactMoney(s: string | null | undefined): number | null { if (!s) return null; const t = s.replace(/\s+/g, ''); if (/\d\.\d{2}$/.test(t)) { const n = Number(t.replace(/[^0-9.]/g, '')); return Number.isFinite(n) && n > 0 ? n : null; } const digits = t.replace(/[^0-9]/g, ''); if (!digits) return null; const n = Number(digits); return Number.isFinite(n) && n > 0 ? n : null; } /** Same as compactMoney but treats the last two digits as cents when no decimal point is present ("$37895" → 378.95). */ export function compactMoneyCents(s: string | null | undefined): number | null { if (!s) return null; const t = s.replace(/\s+/g, ''); if (/\d\.\d{2}$/.test(t)) return compactMoney(t); const digits = t.replace(/[^0-9]/g, ''); if (digits.length < 3) return compactMoney(t); const n = Number(`${digits.slice(0, -2)}.${digits.slice(-2)}`); return Number.isFinite(n) && n > 0 ? n : null; } const 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']; /** Brand from the beginning of a product name (multi-word brands first), else the first word. */ export function brandFromName(name: string): string | null { const n = name.trim(); for (const b of [...KNOWN_BRANDS].sort((a, c) => c.length - a.length)) { if (n.toLowerCase().startsWith(b.toLowerCase() + ' ')) return b === 'FUJIFILM' ? 'Fujifilm' : b; } const first = n.split(/\s+/)[0] ?? ''; return /^[A-Za-z][A-Za-z&.'-]*$/.test(first) ? first : null; } export function stripBrand(name: string, brand: string | null): string { if (!brand) return name.trim(); return name.trim().replace(new RegExp(`^${brand.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+`, 'i'), '').trim() || name.trim(); } /** Fetch a page through Firecrawl (markdown + rawHtml). Quality is judged by the caller's parser. */ export async function fetchFirecrawl(ctx: CrawlContext, url: string, opts: { waitForMs?: number; timeoutMs?: number; parse?: (r: ExtractionResult) => number } = {}): Promise { return ctx.fetch(url, { engines: ['firecrawl'], minQuality: 0.2, waitForMs: opts.waitForMs, timeoutMs: opts.timeoutMs, ...(opts.parse ? { expect: ['title', 'price'], parse: (r) => (opts.parse!(r) > 0 ? { title: 'ok', price: 1 } : null) } : {}), }); } export function absUrl(base: string, href: string | null | undefined): string | null { if (!href) return null; try { return new URL(href, base).toString(); } catch { return null; } } /** Decode a handful of HTML entities that appear in JSON feeds and markdown. */ export function decodeEntities(s: string): string { return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ').replace(/\s+/g, ' ').trim(); }