almeira-web/zapply-app/src/lib/gemini-parser.ts

262 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}