262 lines
9.6 KiB
TypeScript
262 lines
9.6 KiB
TypeScript
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<ExtractedProductData> {
|
||
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();
|
||
}
|
||
}
|