import { GoogleGenAI } from '@google/genai'; import { ExtractedProductData } from '@/types'; import { calculateRetailPrice } from './pricing-engine'; import { getProductSocialProof } from './social-proof'; import { generateAttractiveProductCopy } from './attractive-copy'; import { sanitizePublicationTitle } from './sanitizer'; /** * Extractor robusto de precios mayoristas en COP desde mensajes de WhatsApp * Soporta emojis de dinero (💸, 💰, 💵, 💲), palabras clave, formatos con punto (75.000), 'k' (75k) y 'mil' (75 mil). */ export function extractSupplierPrice(text: string): number { // 1. Buscar patrones con símbolos o emojis de dinero y palabras clave const explicitPattern = /(?:💸|💰|💵|💲|precio|valor|costo|mayorista|mayor|mayoristas|\$)+[\s:=]*\$?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}\s*k|\d{2,3}\s*mil|\d{5,6})/i; const explicitMatch = text.match(explicitPattern); if (explicitMatch) { const raw = explicitMatch[1].toLowerCase().replace(/\s+/g, '').replace(/\./g, ''); if (raw.endsWith('k') || raw.endsWith('mil')) { const num = parseInt(raw.replace('mil', '').replace('k', ''), 10); if (!isNaN(num) && num > 0) return num < 1000 ? num * 1000 : num; } const num = parseInt(raw, 10); if (!isNaN(num)) { if (num >= 40 && num <= 350) return num * 1000; if (num >= 40000 && num <= 500000) return num; } } // 2. Buscar números con formato de miles (ej: 75.000, 80.000, 100.000, 125.500) const dotThousands = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\.(000|500)\b/); if (dotThousands) { const num = parseInt(dotThousands[0].replace(/\./g, ''), 10); if (!isNaN(num) && num >= 40000 && num <= 500000) return num; } // 3. Buscar valores con 'k' o 'mil' (ej: 75k, 80 mil, 95k) const kMatch = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\s*(?:k|mil)\b/i); if (kMatch) { const num = parseInt(kMatch[1], 10); if (!isNaN(num)) return num * 1000; } // 4. Buscar números de 5 o 6 dígitos en rango de precio razonable (40000 a 300000) const rawNumbers = text.match(/\b(4[5-9]\d{3}|[5-9]\d{4}|1\d{5}|2\d{5})\b/g); if (rawNumbers && rawNumbers.length > 0) { const num = parseInt(rawNumbers[0], 10); if (!isNaN(num)) return num; } return 75000; // Valor por defecto representativo para calzado nacional } /** * Extractor de tallas con normalización Unicode (soporta small-caps como ᴛᴀʟʟᴀs ᴅɪsᴘᴏɴɪʙʟᴇs:36/41/44) */ export function extractSizes(rawText: string): string[] { const normalized = rawText .normalize('NFKD') .replace(/[ᴛ]/g, 't') .replace(/[ᴀ]/g, 'a') .replace(/[ʟ]/g, 'l') .replace(/[s]/g, 's') .replace(/[ᴅ]/g, 'd') .replace(/[ɪ]/g, 'i') .replace(/[ᴘ]/g, 'p') .replace(/[ᴏ]/g, 'o') .replace(/[ɴ]/g, 'n') .replace(/[ʙ]/g, 'b') .replace(/[ᴇ]/g, 'e'); const sizes: string[] = []; // Rango explícito (ej: 36 a 43, 36 al 43, 38-43) const rangeMatch = normalized.match( /(?:tallas?|eur|talla|disponibles?)?[^\d\n]*\b(3[4-9]|4[0-6])\s*(?:a|al|-)\s*(3[4-9]|4[0-6])\b/i ); if (rangeMatch) { const start = parseInt(rangeMatch[1], 10); const end = parseInt(rangeMatch[2], 10); if (start <= end && start >= 34 && end <= 46) { for (let s = start; s <= end; s++) sizes.push(s.toString()); return sizes; } } // Sección de tallas o texto completo const tallaSection = normalized.match(/(?:tallas?|eur|disponibles?|talla)[^\n]*/i); const textToScan = tallaSection ? tallaSection[0] : normalized; // Extraer números individuales de 2 dígitos entre 34 y 46 const matches = textToScan.match(/(?:^|[^\d])(3[4-9]|4[0-6])(?=[^\d]|$)/g); if (matches) { matches.forEach((m) => { const num = m.replace(/[^\d]/g, ''); if (num && !sizes.includes(num)) { sizes.push(num); } }); } if (sizes.length === 0) { return ['37', '38', '39', '40', '41', '42', '43']; } return sizes.sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); } /** * Limpia y formatea el nombre del producto desde el mensaje del proveedor */ export function cleanProductName( text: string, detectedBrand: string, extraForbidden: (string | undefined | null)[] = [] ): string { const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0); for (const line of lines) { const lower = line.toLowerCase(); if ( lower.includes('difusion jovic') || lower.includes('jovic') || lower.includes('cc mío') || lower.includes('cc mio') || lower.includes('bodega central') || lower.includes('bodega mayorista') || lower.includes('proveedor') || lower.includes('canal mayorista') || lower.includes('grupo mayorista') || lower.includes('team emporio') || lower.includes('notificaciones') ) { continue; } const clean = line .replace(/🔥|🔴|‼️|✨|🌸|👑|💎|🛍️|🌟|💸|💰|💵|💲|\*/g, '') .replace(/dama\s*y\s*caballero|caballero|dama|unisex|recarga|nueva\s*mercanc[ií]a|[uú]ltima\s*unidad/gi, '') .replace(/c[oó]digo\s*:\s*[\d-]+/gi, '') .replace(/\b\d{2,3}[\.,]\d{3}\b|\b\d{2,3}k\b|\$\s*\d+/gi, '') .replace(/tallas?:.*|precio:.*|eur\s*[\d\/-]+/gi, '') .replace(/\s+/g, ' ') .trim(); const sanitized = sanitizePublicationTitle(clean, extraForbidden, detectedBrand); if (sanitized.length >= 3 && !/^\d+$/.test(sanitized) && !/^\d+[\.,]\d+$/.test(sanitized)) { return sanitized; } } return sanitizePublicationTitle( `${detectedBrand} Urban Edition`, extraForbidden, detectedBrand ); } export async function parseSupplierMessage( text: string, extraForbidden: (string | undefined | null)[] = [] ): Promise { const apiKey = process.env.GEMINI_API_KEY; const fallbackExtract = (): ExtractedProductData => { const price = extractSupplierPrice(text); const sizes = extractSizes(text); let brand = 'Nike'; if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas'; else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan'; else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance'; else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma'; else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics'; else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans'; else if (/coach/i.test(text)) brand = 'Coach'; const rawClean = cleanProductName(text, brand, extraForbidden); const cleanName = sanitizePublicationTitle(rawClean, extraForbidden, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { name: cleanName, brand, sizes, supplierPrice: price, suggestedRetailPrice: calculateRetailPrice(price, 35, 30000), category: 'Calzado / Sneakers', confidence: 0.92, rating: proof.rating, reviewsCount: proof.reviewsCount, salesCount: proof.salesCount, originBadge: proof.badge, }; }; if (!apiKey) { return fallbackExtract(); } try { const ai = new GoogleGenAI({ apiKey }); const prompt = `Eres un asistente experto en e-commerce y calzado deportivo en Colombia. Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp. Extrae los siguientes campos en formato JSON estricto: - "name": Nombre comercial limpio del modelo (ej: "Adidas Samba OG", "Nike Dunk Low Retro Panda", "New Balance 9060"). - "brand": Marca comercial ("Adidas", "Nike", "Jordan", "New Balance", "Puma", "Asics", "Vans", etc.). - "sizes": Array de números de tallas disponibles (ej: ["36", "41", "44"] o ["38", "39", "40", "41", "42", "43"]). Normaliza caracteres especiales o fuentes estilizadas. - "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 75000, 80000, 100000, 120000). Presta mucha atención a símbolos como 💸, 💰, $, 75.000, 75k, etc. - "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", etc.). Mensaje del grupo: """ ${text} """ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto adicional.`; const response = await ai.models.generateContent({ model: 'gemini-2.5-flash', contents: prompt, }); const outputText = response.text || ''; const cleanJson = outputText.replace(/```json|```/gi, '').trim(); const parsed = JSON.parse(cleanJson); const supplierPrice = typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000 ? parsed.supplierPrice : extractSupplierPrice(text); const sizes = Array.isArray(parsed.sizes) && parsed.sizes.length > 0 ? parsed.sizes.map(String) : extractSizes(text); let brand = parsed.brand || 'Nike'; if (/samba|campus|bad bunny|forum|gazelle/i.test(parsed.name || text)) brand = 'Adidas'; if (/9060|550|1906/i.test(parsed.name || text)) brand = 'New Balance'; const rawName = parsed.name || cleanProductName(text, brand, extraForbidden); const cleanName = sanitizePublicationTitle(rawName, extraForbidden, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { name: cleanName, brand, sizes, supplierPrice, suggestedRetailPrice: calculateRetailPrice(supplierPrice, 35, 30000), category: parsed.category || 'Sneakers', confidence: 0.98, rating: proof.rating, reviewsCount: proof.reviewsCount, salesCount: proof.salesCount, originBadge: proof.badge, }; } catch (err) { console.warn('Fallo llamada Gemini, utilizando extractor regex de alta precisión:', err); return fallbackExtract(); } }