import { z } from 'zod'; import type { CrawlContext } from '@rareindex/connectors'; import { CurrencySchema, extractYear, type CurrencyCode } from '@rareindex/shared'; /** * Shared helpers for luxury / watch / sneaker marketplace connectors. Conventions match the * existing chrono24 (watches: brand/model/reference) and novelship (sneakers: style_code) connectors * so entity resolution merges records across sources. */ // ---------- watches ---------- export const WATCH_BRAND_CATEGORY: Record = { rolex: 'rolex', 'patek philippe': 'patek_philippe', patek: 'patek_philippe', 'audemars piguet': 'audemars_piguet', audemars: 'audemars_piguet', omega: 'omega', }; export function watchCategory(brand: string | null | undefined): string { const b = (brand ?? '').toLowerCase().trim(); for (const [k, v] of Object.entries(WATCH_BRAND_CATEGORY)) if (b === k || b.startsWith(k)) return v; return 'other_watches'; } export const WATCH_BRANDS = ['Rolex', 'Patek Philippe', 'Audemars Piguet', 'Omega', 'Cartier', 'Tudor', 'Breitling', 'IWC', 'Jaeger-LeCoultre', 'A. Lange & Söhne', 'Vacheron Constantin', 'Richard Mille', 'Grand Seiko', 'Seiko', 'TAG Heuer', 'Panerai', 'Hublot', 'Zenith', 'Breguet', 'Blancpain', 'F.P. Journe', 'MB&F', 'De Bethune', 'Greubel Forsey', 'Urwerk', 'Chopard', 'Piaget', 'Bulgari', 'Bvlgari', 'Franck Muller', 'Girard-Perregaux', 'Glashütte Original', 'H. Moser & Cie', 'Jaquet Droz', 'Laurent Ferrier', 'Parmigiani', 'Roger Dubuis', 'Ulysse Nardin', 'Czapek', 'Longines', 'Tiffany & Co.', 'Hermès', 'Chanel', 'Louis Vuitton', 'Bell & Ross', 'Nomos', 'Oris', 'Sinn']; /** Same heuristics as chrono24: 116500LN, 126610LV, 5711/1A-010, 15400ST.OO.1220ST.01, 311.30.42.30.01.005, RM 011 */ export const WATCH_REF_RE = /\b(\d{4,6}[A-Z]{0,3}(?:\/\d[A-Z0-9]*)?(?:-\d{3})?|\d{5}[A-Z]{2}\.[A-Z]{2}\.\d{4}[A-Z]{2}\.\d{2}|\d{3}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3}|RM\s?\d{2,3}(?:-\d{2})?|W[A-Z0-9]{6,8}|[A-Z]{1,3}\d{3,5}[A-Z]{0,2}\.[A-Z0-9.]{3,})\b/; export function watchReferenceFromText(text: string): string | null { return [...text.matchAll(new RegExp(WATCH_REF_RE.source, 'g'))].map((m) => m[1]!.replace(/\s+/g, ' ')).find((r) => !/^(18|19|20)\d{2}$/.test(r) && !/^\d{4}$/.test(r)) ?? null; } export function watchMaterial(text: string): string | null { const l = text.toLowerCase(); if (/platinum/.test(l)) return 'platinum'; if (/two[- ]tone|rolesor|steel (?:and|&) (?:yellow |rose |everose )?gold/.test(l)) return 'two-tone'; if (/yellow gold|rose gold|everose|white gold|18k|18ct|gold/.test(l)) return 'gold'; if (/titanium/.test(l)) return 'titanium'; if (/ceramic/.test(l)) return 'ceramic'; if (/steel|stainless|\bss\b/.test(l)) return 'steel'; return null; } export function watchCompleteness(text: string): string | null { const l = text.toLowerCase(); if (/full set|box (?:and|&|\/|\+) papers|box\/papers|complete set|box and card|box & card/.test(l)) return 'full_set'; if (/papers|warranty card|guarantee/.test(l)) return 'papers_only'; if (/\bbox\b/.test(l)) return 'box_only'; return null; } export function watchConditionRaw(text: string): string | null { const l = text.toLowerCase(); if (/unworn|brand new|\bnew\b|never worn/.test(l)) return 'Unworn'; if (/like new|\bmint\b|excellent|pristine/.test(l)) return 'Excellent'; if (/very good|good/.test(l)) return 'Good'; if (/fair/.test(l)) return 'Fair'; if (/pre-owned|used|preowned/.test(l)) return 'Pre-owned'; return null; } export function caseSize(text: string): string | null { const m = text.match(/\b(\d{2}(?:\.\d)?)\s?mm\b/i); return m ? `${m[1]}mm` : null; } // ---------- sneakers ---------- export function sneakerCategory(brand: string | null | undefined, name = ''): string { const b = `${brand ?? ''} ${name}`.toLowerCase(); if (/jordan|nike/.test(b)) return 'nike_jordan'; if (/adidas|yeezy/.test(b)) return 'adidas_yeezy'; return 'new_balance_asics_other'; } /** Style codes: FV5029-003, DZ5485-612, IE7264, GX1332, M990GL6, BB550STA, 1201A019-100, CT8012-170 */ export const STYLE_CODE_RE = /\b([A-Z]{1,2}\d{4,5}-\d{3}|\d{6}-\d{3}|[A-Z]{2}\d{4}|[A-Z]{1,3}\d{3,4}[A-Z]{1,4}\d{0,2}|\d{4}[A-Z]\d{3}-\d{3}|[A-Z]{2}\d{4}-\d{3})\b/; export function styleCodeFromText(text: string): string | null { const m = text.toUpperCase().match(STYLE_CODE_RE); return m ? m[1]! : null; } // ---------- handbags ---------- const BAG_MODELS = ['Birkin', 'Kelly', 'Constance', 'Lindy', 'Evelyne', 'Picotin', 'Garden Party', 'Bolide', 'Classic Flap', 'Classic Double Flap', 'Double Flap', 'Single Flap', '2.55', 'Reissue', 'Boy', 'Coco Handle', '19 Flap', 'Deauville', 'Gabrielle', 'Speedy', 'Neverfull', 'Alma', 'Keepall', 'Pochette Métis', 'Pochette Metis', 'Capucines', 'Twist', 'OnTheGo', 'Onthego', 'Multi Pochette', 'Noé', 'Petite Malle', 'Lady Dior', 'Saddle', 'Book Tote', 'Caro', 'Diorama', 'Jackie', 'Dionysus', 'Marmont', 'Bamboo', 'Horsebit', 'Saint Louis', 'Goyardine', 'Artois', 'Anjou', 'Baguette', 'Peekaboo', 'Cassette', 'Jodie', 'Pouch', 'Puzzle', 'Hammock', 'Galleria', 'Re-Edition', 'Cleo', 'Luggage', 'Triomphe', 'Loulou', 'Kate', 'Sac de Jour', 'Le 5 à 7']; export function bagModel(title: string): string | null { const t = title.toLowerCase(); for (const m of BAG_MODELS) if (t.includes(m.toLowerCase())) return m; return null; } export function bagSize(title: string): string | null { const m = title.match(/\b(\d{2})\b(?!\s?mm)/) ?? title.match(/\b(Mini|Small|Medium|Large|Jumbo|Maxi|PM|MM|GM|Nano|Micro)\b/i); return m ? m[1]! : null; } export const BAG_MATERIALS = ['Togo', 'Epsom', 'Clemence', 'Clémence', 'Swift', 'Box Calf', 'Chevre', 'Chèvre', 'Ostrich', 'Crocodile', 'Alligator', 'Lizard', 'Caviar', 'Lambskin', 'Calfskin', 'Patent', 'Canvas', 'Monogram', 'Damier', 'Epi', 'Vernis', 'Empreinte', 'Suede', 'Nylon', 'Denim', 'Tweed', 'Python', 'Goatskin', 'Goyardine', 'Raffia']; export function bagMaterial(title: string): string | null { const t = title.toLowerCase(); for (const m of BAG_MATERIALS) if (t.includes(m.toLowerCase())) return m; return null; } export function bagHardware(title: string): string | null { const m = title.match(/\b(Gold|Palladium|Silver|Rose Gold|Ruthenium|Brushed Gold|Light Gold|Aged Gold|Antique Gold|Gunmetal|Permabrass)\s+Hardware\b/i) ?? title.match(/\b(GHW|PHW|RGHW|SHW|BGHW)\b/); return m ? m[1]! : null; } const LUXURY_BAG_BRANDS = ['hermès', 'hermes', 'chanel', 'louis vuitton', 'dior', 'christian dior', 'gucci', 'goyard', 'fendi', 'bottega veneta', 'prada', 'celine', 'céline', 'loewe', 'saint laurent', 'ysl', 'balenciaga', 'givenchy', 'valentino', 'burberry', 'miu miu', 'chloé', 'chloe', 'jacquemus', 'the row']; export function isLuxuryBagBrand(brand: string | null | undefined): boolean { return LUXURY_BAG_BRANDS.includes((brand ?? '').toLowerCase().trim()); } // ---------- Shopify ---------- export const ShopifyVariantSchema = z.object({ id: z.number(), title: z.string(), price: z.string(), compare_at_price: z.string().nullable().optional(), available: z.boolean(), sku: z.string().nullable().optional(), option1: z.string().nullable().optional(), updated_at: z.string().optional(), }); export const ShopifyProductSchema = z.object({ id: z.number(), title: z.string(), handle: z.string(), body_html: z.string().nullable().optional(), published_at: z.string().nullable().optional(), created_at: z.string().optional(), updated_at: z.string().optional(), vendor: z.string().nullable().optional(), product_type: z.string().nullable().optional(), tags: z.array(z.string()).optional(), variants: z.array(ShopifyVariantSchema), images: z.array(z.object({ src: z.string() })).optional(), options: z.array(z.object({ name: z.string(), values: z.array(z.string()).optional() })).optional(), }); export type ShopifyProduct = z.infer; /** Trim a Shopify product to what normalize() needs (payloads stay compact). */ export function trimShopifyProduct(p: ShopifyProduct): ShopifyProduct { return { id: p.id, title: p.title, handle: p.handle, body_html: p.body_html ? p.body_html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 1200) : null, published_at: p.published_at ?? null, updated_at: p.updated_at, vendor: p.vendor ?? null, product_type: p.product_type ?? null, tags: (p.tags ?? []).filter((t) => !/^(updated-|amazon-|tiktok-)/.test(t)).slice(0, 40), variants: p.variants.slice(0, 20).map((v) => ({ id: v.id, title: v.title, price: v.price, compare_at_price: v.compare_at_price ?? null, available: v.available, sku: v.sku ?? null, option1: v.option1 ?? null, updated_at: v.updated_at })), images: (p.images ?? []).slice(0, 2).map((i) => ({ src: i.src })), options: (p.options ?? []).map((o) => ({ name: o.name })), }; } /** Fetch one page of a public Shopify products.json feed (plain HTTPS, no auth). */ export async function fetchShopifyPage(ctx: CrawlContext, base: string, path: string, page: number, limit = 250): Promise<{ products: ShopifyProduct[]; res: Awaited> }> { const url = `${base}${path}?limit=${limit}&page=${page}`; const res = await ctx.fetch(url, { engines: ['api'], responseType: 'json', expect: ['title', 'price'], parse: (r) => { const j = r.json as { products?: Array<{ title?: string; variants?: Array<{ price?: string }> }> } | null; const f = j?.products?.[0]; return f ? { title: f.title, price: f.variants?.[0]?.price } : { title: 'empty-page', price: 0 }; } }); const parsed = z.object({ products: z.array(ShopifyProductSchema) }).safeParse(res.json); return { products: parsed.success ? parsed.data.products : [], res }; } export function currencyOr(code: string | null | undefined, fallback: CurrencyCode): CurrencyCode { const c = CurrencySchema.safeParse((code ?? '').toUpperCase()); return c.success ? c.data : fallback; } export function yearFrom(text: string | null | undefined): number | null { return text ? extractYear(text) : null; } export function moneyNumber(s: string | number | null | undefined): number | null { if (s === null || s === undefined) return null; const n = typeof s === 'number' ? s : Number(String(s).replace(/[^0-9.]/g, '')); return Number.isFinite(n) && n > 0 ? n : null; } // ---------- Firecrawl rawHtml (scripts kept) ---------- import type { ExtractionResult } from '@rareindex/shared'; /** * The shared Firecrawl engine requests the `html` format, which strips