diff --git a/deploy_dripzone_vps.py b/deploy_dripzone_vps.py index d6b1b17..bff8c30 100644 --- a/deploy_dripzone_vps.py +++ b/deploy_dripzone_vps.py @@ -44,15 +44,30 @@ def deploy(): sftp.put(ARCHIVE_NAME, REMOTE_TAR) apk_local = r"c:\Users\Carlos\dev\almeira-web\dripzone-app\public\DripZone.apk" if os.path.exists(apk_local): - print(" -> Subiendo DripZone.apk directamente a public/...") + local_size = os.path.getsize(apk_local) + remote_apk = f"{REMOTE_DIR}/public/DripZone.apk" + should_upload = True try: - sftp.mkdir(f"{REMOTE_DIR}/public") + r_stat = sftp.stat(remote_apk) + if r_stat.st_size == local_size: + print(f" -> DripZone.apk ({local_size/(1024*1024):.1f} MB) ya se encuentra actualizado en el VPS. Omitiendo subida redundante.") + should_upload = False except: pass - sftp.put(apk_local, f"{REMOTE_DIR}/public/DripZone.apk") - print(" [OK] DripZone.apk subido al VPS.") + + if should_upload: + print(f" -> Subiendo DripZone.apk ({local_size/(1024*1024):.1f} MB) a public/...") + try: + sftp.mkdir(f"{REMOTE_DIR}/public") + except: + pass + try: + sftp.put(apk_local, remote_apk) + print(" [OK] DripZone.apk subido al VPS.") + except Exception as e: + print(f" [Aviso] Error en subida SFTP de APK: {e}. Continuando con el despliegue...") sftp.close() - print("[OK] Archivos subidos exitosamente.") + print("[OK] Archivos base subidos exitosamente.") commands = f""" mkdir -p {REMOTE_DIR} diff --git a/dripzone-app/src/app/admin/productos/page.tsx b/dripzone-app/src/app/admin/productos/page.tsx index c97fa68..7262db6 100644 --- a/dripzone-app/src/app/admin/productos/page.tsx +++ b/dripzone-app/src/app/admin/productos/page.tsx @@ -9,6 +9,9 @@ import { GroupProductsModal } from '@/components/admin/GroupProductsModal'; import { ManageVariantsModal } from '@/components/admin/ManageVariantsModal'; import { renderClientTemplate, initClientTemplates } from '@/lib/templates-client'; import { OptimizedImage } from '@/components/ui/OptimizedImage'; +import { sanitizePublicationTitle, sanitizePublicationText } from '@/lib/sanitizer'; +import { cleanProductName, detectBrandFromText } from '@/lib/gemini-parser'; +import { generateAttractiveProductCopy } from '@/lib/attractive-copy'; @@ -150,16 +153,25 @@ export default function AdminProductsPage() { const chosenSupplier = suppliers.find((s) => s.id === selectedSupplierId) || suppliers[0]; + const cleanProdName = sanitizePublicationTitle(name, [chosenSupplier.name], brand); + const cleanProdDesc = generateAttractiveProductCopy({ + name: cleanProdName, + brand, + retailPrice: parseInt(retailPrice, 10) || 125000, + forbiddenTerms: [chosenSupplier.name], + }); + const newProd: Product = { id: `snk-${Date.now()}`, - name, + name: cleanProdName, + modelName: cleanProdName, brand, supplierPrice: parseInt(supplierPrice, 10) || 90000, retailPrice: parseInt(retailPrice, 10) || 125000, originalPrice: Math.round((parseInt(retailPrice, 10) || 125000) * 1.35), sizes, images: [imageUrl], - description: 'Zapatillas de alta calidad garantizada para entrega inmediata.', + description: cleanProdDesc, category, inStock: true, isActive: isActiveNew, @@ -201,9 +213,12 @@ export default function AdminProductsPage() { const sizes = editSizesInput.split(',').map((s) => s.trim()).filter(Boolean); const chosenSupplier = suppliers.find((s) => s.id === editSupplierId) || suppliers[0]; + const cleanProdName = sanitizePublicationTitle(editName, [chosenSupplier.name], editBrand); + const cleanProdDesc = sanitizePublicationText(editingProduct.description || '', [chosenSupplier.name]); const updates = { - name: editName, + name: cleanProdName, + modelName: cleanProdName, brand: editBrand, supplierPrice: parseInt(editSupplierPrice, 10) || editingProduct.supplierPrice, retailPrice: parseInt(editRetailPrice, 10) || editingProduct.retailPrice, @@ -211,6 +226,7 @@ export default function AdminProductsPage() { sizes, images: [editImageUrl || editingProduct.images[0]], category: editCategory, + description: cleanProdDesc, supplierId: chosenSupplier.id, supplierName: chosenSupplier.name, supplierPhone: chosenSupplier.phone, @@ -227,7 +243,7 @@ export default function AdminProductsPage() { const data = await res.json(); if (data.success) { setEditingProduct(null); - setToastMessage(`✅ Publicación "${editName}" actualizada con éxito.`); + setToastMessage(`✅ Publicación "${cleanProdName}" actualizada con éxito.`); } } catch (e) { console.error(e); @@ -238,19 +254,11 @@ export default function AdminProductsPage() { const handleProcessSinglePostWithAI = () => { if (!rawText.trim()) return; - // Extracción inteligente - const cleanTitle = rawText - .split('\n')[0] - .replace(/🔥|👟|💥|⚡|DISPONIBLE|LLEGANDO|HOY|A BODEGA/gi, '') - .trim(); - - // Detectar marca - let detectedBrand = 'Nike'; - if (/adidas/i.test(rawText)) detectedBrand = 'Adidas'; - if (/jordan/i.test(rawText)) detectedBrand = 'Jordan'; - if (/new balance/i.test(rawText)) detectedBrand = 'New Balance'; - if (/puma/i.test(rawText)) detectedBrand = 'Puma'; - if (/vans/i.test(rawText)) detectedBrand = 'Vans'; + const brandDet = detectBrandFromText(rawText); + const detectedBrand = brandDet.brand !== 'Urbano / Variado' ? brandDet.brand : 'Nike'; + const allSupplierNames = suppliers.map((s) => s.name); + const rawClean = brandDet.modelName || cleanProductName(rawText, detectedBrand, allSupplierNames); + const cleanTitle = sanitizePublicationTitle(rawClean, allSupplierNames, detectedBrand); // Detectar precio const priceMatch = rawText.match(/\$?\s*(\d{2,3})[.,]?(\d{3})/); @@ -265,36 +273,48 @@ export default function AdminProductsPage() { // Detectar tallas const sizes = ['37', '38', '39', '40', '41', '42', '43']; + const chosenSupplier = suppliers[0]; + const desc = generateAttractiveProductCopy({ + name: cleanTitle, + brand: detectedBrand, + retailPrice: retail, + forbiddenTerms: allSupplierNames, + }); + setAiExtracted({ - name: cleanTitle || 'Zapatilla Urbana Exclusiva', + name: cleanTitle, brand: detectedBrand, supplierPrice: cost, retailPrice: retail, sizes, images: [rawImageUrl || 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], category: 'Sneakers / Urbano', - description: 'Zapatillas calidad 1.1 garantizada para entrega inmediata.', + description: desc, isActive: true, inStock: true, originBadge: 'Bodega Central', - supplierName: 'DIFUSION JOVIC STORE', - supplierPhone: '573134848203', + supplierName: chosenSupplier?.name || 'DIFUSION JOVIC STORE', + supplierPhone: chosenSupplier?.phone || '573134848203', }); }; const handlePublishAiProduct = async () => { if (!aiExtracted) return; + const cleanName = sanitizePublicationTitle(aiExtracted.name || 'Zapatilla Urbana', [aiExtracted.supplierName], aiExtracted.brand); + const cleanDesc = sanitizePublicationText(aiExtracted.description || '', [aiExtracted.supplierName]); + const newProd: Product = { id: `snk-${Date.now()}`, - name: aiExtracted.name || 'Zapatilla', + name: cleanName, + modelName: cleanName, brand: aiExtracted.brand || 'Nike', supplierPrice: aiExtracted.supplierPrice || 90000, retailPrice: aiExtracted.retailPrice || 125000, originalPrice: Math.round((aiExtracted.retailPrice || 125000) * 1.35), sizes: aiExtracted.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: aiExtracted.images || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: aiExtracted.description || 'Zapatillas urbanas', + description: cleanDesc, category: aiExtracted.category || 'Sneakers / Urbano', inStock: true, isActive: true, diff --git a/dripzone-app/src/app/api/whatsapp-feed/route.ts b/dripzone-app/src/app/api/whatsapp-feed/route.ts index df4b6d0..56d3b63 100644 --- a/dripzone-app/src/app/api/whatsapp-feed/route.ts +++ b/dripzone-app/src/app/api/whatsapp-feed/route.ts @@ -4,6 +4,7 @@ import { parseSupplierMessage } from '@/lib/gemini-parser'; import { performMarketIntelligenceSearch } from '@/lib/market-intelligence'; import { getProductSocialProof } from '@/lib/social-proof'; import { generateAttractiveProductCopy } from '@/lib/attractive-copy'; +import { sanitizePublicationTitle, sanitizePublicationText } from '@/lib/sanitizer'; import { getSupplierPostsFromAppwrite, saveSupplierPostToAppwrite, @@ -168,10 +169,14 @@ export async function GET(request: Request) { ); } - // Si ya existía con datos extraídos en Appwrite, usarlo directamente + // Si ya existía con datos extraídos en Appwrite, usarlo directamente asegurando sanitización let extracted = existingInAppwrite?.extractedProduct; if (!extracted) { - extracted = await parseSupplierMessage(text || groupName, imageUrls[0]); + // NUNCA pasar groupName como texto de calzado. Si text está vacío, pasar string vacío para que Gemini use la imagen y visión sin contaminar el nombre del producto + const cleanInputText = text || ''; + extracted = await parseSupplierMessage(cleanInputText, imageUrls[0], [groupName]); + } else if (extracted && extracted.name) { + extracted.name = sanitizePublicationTitle(extracted.name, [groupName], extracted.brand); } const timestamp = record.messageTimestamp @@ -248,10 +253,19 @@ export async function POST(request: Request) { post.extractedProduct?.suggestedRetailPrice || (post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000); - const prodName = customName || post.extractedProduct?.name || 'Zapatillas Urbanas'; + const rawProdName = customName || post.extractedProduct?.name || 'Zapatillas Urbanas'; const prodBrand = customBrand || post.extractedProduct?.brand || 'Urbano / Variado'; + const forbiddenContext = [post.channelName, post.supplierName]; + const prodName = sanitizePublicationTitle(rawProdName, forbiddenContext, prodBrand); const proof = getProductSocialProof({ name: prodName, brand: prodBrand }); + const prodDescription = generateAttractiveProductCopy({ + name: prodName, + brand: prodBrand, + retailPrice, + forbiddenTerms: forbiddenContext, + }); + const newProduct = { id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name: prodName, @@ -267,7 +281,7 @@ export async function POST(request: Request) { marketComparisonSummary: post.extractedProduct?.marketComparisonSummary || marketIntelligence?.marketComparisonSummary, sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }), + description: prodDescription, category: post.extractedProduct?.category || 'Calzado / Sneakers', inStock: true, isActive: true, @@ -299,10 +313,19 @@ export async function POST(request: Request) { p.extractedProduct?.suggestedRetailPrice || (p.extractedProduct?.supplierPrice ? p.extractedProduct.supplierPrice + 35000 : 125000); - const prodName = p.extractedProduct?.name || 'Zapatillas Urbanas'; + const rawProdName = p.extractedProduct?.name || 'Zapatillas Urbanas'; const prodBrand = p.extractedProduct?.brand || 'Urbano / Variado'; + const forbiddenContext = [p.channelName, p.supplierName]; + const prodName = sanitizePublicationTitle(rawProdName, forbiddenContext, prodBrand); const proof = getProductSocialProof({ name: prodName, brand: prodBrand }); + const prodDescription = generateAttractiveProductCopy({ + name: prodName, + brand: prodBrand, + retailPrice, + forbiddenTerms: forbiddenContext, + }); + const newProd = { id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name: prodName, @@ -317,7 +340,7 @@ export async function POST(request: Request) { marketComparisonSummary: p.extractedProduct?.marketComparisonSummary, sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }), + description: prodDescription, category: p.extractedProduct?.category || 'Calzado / Sneakers', inStock: true, isActive: true, diff --git a/dripzone-app/src/lib/attractive-copy.ts b/dripzone-app/src/lib/attractive-copy.ts index 846c655..48938e9 100644 --- a/dripzone-app/src/lib/attractive-copy.ts +++ b/dripzone-app/src/lib/attractive-copy.ts @@ -1,7 +1,12 @@ +import { sanitizePublicationTitle, sanitizePublicationText } from './sanitizer'; + /** * Generador de Textos Comerciales Persuasivos y Atractivos para Calzado Deportivo * Produce descripciones llamativas con llamados a la acción (CTA), beneficios de calidad G5/1.1, * garantía de entrega y estética street-wear colombiana para maximizar conversiones. + * + * REGLA ESTRICTA: NI en el título NI en el texto de la publicación se debe incluir el nombre + * del grupo de WhatsApp ni el proveedor del producto. */ export function generateAttractiveProductCopy(product: { @@ -10,21 +15,26 @@ export function generateAttractiveProductCopy(product: { category?: string; supplierPrice?: number; retailPrice?: number; + forbiddenTerms?: (string | undefined | null)[]; }): string { const brand = product.brand || 'Sneakers'; - const name = product.name || 'Zapatillas Exclusivas'; + const cleanName = sanitizePublicationTitle( + product.name || 'Zapatillas Exclusivas', + product.forbiddenTerms, + brand + ); const hooks = [ - `🔥 ¡Lleva tu outfit al siguiente nivel con las icónicas ${name}! Diseñadas para quienes no negocian entre estilo de alto impacto y máxima comodidad para el día a día.`, - `⚡ Las ${name} son el drop definitivo que tu colección estaba esperando. Silueta imbatible, acabados prémium y una estética urbana que no pasa desapercibida.`, - `💎 Edición Prémium ${brand}: ${name}. Fabricadas con materiales de máxima durabilidad, textura prémium y una combinación de colores pensada para resaltar en cualquier ocasión.`, - `🚀 Dale un upgrade a tus pasos con las exclusivas ${name}. Comodidad extrema desde el primer uso, amortiguación reactiva y estilo 100% garantizado.`, + `🔥 ¡Lleva tu outfit al siguiente nivel con las icónicas ${cleanName}! Diseñadas para quienes no negocian entre estilo de alto impacto y máxima comodidad para el día a día.`, + `⚡ Las ${cleanName} son el drop definitivo que tu colección estaba esperando. Silueta imbatible, acabados prémium y una estética urbana que no pasa desapercibida.`, + `💎 Edición Prémium ${brand}: ${cleanName}. Fabricadas con materiales de máxima durabilidad, textura prémium y una combinación de colores pensada para resaltar en cualquier ocasión.`, + `🚀 Dale un upgrade a tus pasos con las exclusivas ${cleanName}. Comodidad extrema desde el primer uso, amortiguación reactiva y estilo 100% garantizado.`, ]; - const seed = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + const seed = cleanName.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); const selectedHook = hooks[Math.abs(seed) % hooks.length]; - return `${selectedHook} + const rawCopy = `${selectedHook} ✨ *¿Por qué todos las eligen?* • 💎 *Calidad G5 / 1.1 Prémium:* Materiales de alto rendimiento, costuras reforzadas y terminaciones impecables. @@ -33,5 +43,7 @@ export function generateAttractiveProductCopy(product: { • 💵 *Pago 100% Contraentrega:* Realizas tu pedido con total tranquilidad y pagas al recibir en tu puerta. • 🛡️ *Garantía Directa:* Revisas tu producto y verificas tu talla con total respaldo. -⚠️ *Disponibilidad Limitada:* Pocas unidades disponibles por talla en bodega nacional. ¡Asegura tu par ahora antes de que se agoten!`; +⚠️ *Disponibilidad Limitada:* Pocas unidades disponibles por talla en inventario nacional. ¡Asegura tu par ahora antes de que se agoten!`; + + return sanitizePublicationText(rawCopy, product.forbiddenTerms); } diff --git a/dripzone-app/src/lib/data-service.ts b/dripzone-app/src/lib/data-service.ts index b7c1f7d..7796d50 100644 --- a/dripzone-app/src/lib/data-service.ts +++ b/dripzone-app/src/lib/data-service.ts @@ -3,6 +3,7 @@ import { normalizeModelKey, groupProductsByModel } from './model-grouper'; import { generateAttractiveProductCopy } from './attractive-copy'; import { getProductSocialProof } from './social-proof'; import { DEFAULT_MESSAGE_TEMPLATES, interpolateVariables } from './templates-service'; +import { sanitizePublicationTitle, sanitizePublicationText } from './sanitizer'; export const INITIAL_SUPPLIERS: Supplier[] = [ @@ -206,7 +207,23 @@ class DataService { addProduct(product: Product): Product { const brand = product.brand || 'Sneakers'; - const baseModelName = product.modelName || product.name; + const cleanName = sanitizePublicationTitle(product.name, [product.supplierName], brand); + const baseModelName = sanitizePublicationTitle(product.modelName || cleanName, [product.supplierName], brand); + const cleanDescription = sanitizePublicationText( + product.description || + generateAttractiveProductCopy({ + name: cleanName, + brand, + retailPrice: product.retailPrice, + forbiddenTerms: [product.supplierName], + }), + [product.supplierName] + ); + + product.name = cleanName; + product.modelName = baseModelName; + product.description = cleanDescription; + const modelKey = normalizeModelKey(baseModelName, brand); // Buscar si ya existe un producto del mismo modelo @@ -245,13 +262,15 @@ class DataService { }); } + existing.name = cleanName; + existing.modelName = baseModelName; existing.colors = existingColors; existing.images = Array.from(new Set([...existing.images, ...product.images])); existing.sizes = Array.from(new Set([...existing.sizes, ...(product.sizes || [])])).sort( (a, b) => parseInt(a, 10) - parseInt(b, 10) ); if (!existing.description || existing.description.length < 50) { - existing.description = generateAttractiveProductCopy(existing); + existing.description = cleanDescription; } return existing; } @@ -274,9 +293,10 @@ class DataService { const formattedProduct: Product = { ...product, + name: cleanName, modelName: baseModelName, colors: initialColors, - description: product.description || generateAttractiveProductCopy(product), + description: cleanDescription, rating: product.rating || proof.rating, reviewsCount: product.reviewsCount || proof.reviewsCount, salesCount: product.salesCount || proof.salesCount, diff --git a/dripzone-app/src/lib/gemini-parser.ts b/dripzone-app/src/lib/gemini-parser.ts index ffc1f7f..bd36041 100644 --- a/dripzone-app/src/lib/gemini-parser.ts +++ b/dripzone-app/src/lib/gemini-parser.ts @@ -3,6 +3,7 @@ import { ExtractedProductData } from '@/types'; import { calculateRetailPrice } from './pricing-engine'; import { getProductSocialProof } from './social-proof'; import { generateAttractiveProductCopy } from './attractive-copy'; +import { sanitizePublicationTitle } from './sanitizer'; /** * Catálogo maestro de marcas y sus firmas de modelos y palabras clave exclusivas @@ -479,11 +480,40 @@ export function extractSizes(rawText: string): string[] { } /** - * Limpia y formatea el nombre del producto + * Limpia y formatea el nombre del producto garantizando que NUNCA se incluyan nombres + * de proveedores, bodegas, canales o grupos de WhatsApp. */ -export function cleanProductName(text: string, detectedBrand: string): string { +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(); + // Omitir líneas completas que son encabezados o nombres de proveedores/grupos + 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('canal de difusión') || + lower.includes('canal de difusion') || + lower.includes('grupo mayorista') || + lower.includes('grupo whatsapp') || + lower.includes('team emporio') || + lower.includes('notificaciones') || + lower.includes('medios y prensa') || + lower.includes('calzado mayorista') || + lower.includes('san andresito') + ) { + 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, '') @@ -493,14 +523,17 @@ export function cleanProductName(text: string, detectedBrand: string): string { .replace(/\s+/g, ' ') .trim(); - if (clean.length >= 3 && !/^\d+$/.test(clean) && !/^\d+[\.,]\d+$/.test(clean)) { - return clean - .split(' ') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) - .join(' '); + const sanitized = sanitizePublicationTitle(clean, extraForbidden, detectedBrand); + if (sanitized.length >= 3 && !/^\d+$/.test(sanitized) && !/^\d+[\.,]\d+$/.test(sanitized)) { + return sanitized; } } - return detectedBrand !== 'Urbano / Variado' ? `${detectedBrand} Urban Edition` : 'Zapatilla Urbana Nacional'; + + return sanitizePublicationTitle( + detectedBrand !== 'Urbano / Variado' ? `${detectedBrand} Urban Edition` : 'Zapatilla Urbana Nacional', + extraForbidden, + detectedBrand + ); } /** @@ -533,10 +566,14 @@ function extractBase64Payload(imageInput?: string): { mimeType: string; data: st * Parser Inteligente de Mensajes y Visión Multimodal con Gemini * Reconoce logos en la foto (Swoosh, 3 Rayas, Trefoil, N de New Balance, On Clouds, Formstrip de Puma, etc.) * y extrae nombre comercial, marca exacta, tallas y precio mayorista. + * + * REGLA ESTRICTA: NI en el título NI en el texto de la publicación se debe incluir el nombre + * del grupo de WhatsApp ni el proveedor del producto. */ export async function parseSupplierMessage( text: string, - imageBase64OrUrl?: string + imageBase64OrUrl?: string, + extraForbidden: (string | undefined | null)[] = [] ): Promise { const apiKey = process.env.GEMINI_API_KEY; @@ -546,7 +583,8 @@ export async function parseSupplierMessage( const sizes = extractSizes(text); const brandDetection = detectBrandFromText(text); const brand = brandDetection.brand; - const cleanName = brandDetection.modelName || cleanProductName(text, brand); + const rawClean = brandDetection.modelName || cleanProductName(text, brand, extraForbidden); + const cleanName = sanitizePublicationTitle(rawClean, extraForbidden, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { @@ -572,9 +610,20 @@ export async function parseSupplierMessage( const ai = new GoogleGenAI({ apiKey }); const imagePayload = extractBase64Payload(imageBase64OrUrl); + const forbiddenTermsStr = extraForbidden.filter(Boolean).join(', '); + const forbiddenClause = forbiddenTermsStr + ? `Términos específicos PROHIBIDOS a omitir absolutamente: "${forbiddenTermsStr}".` + : ''; + const systemPrompt = `Eres un experto mundial en reconocimiento de calzado deportivo y zapatillas urbanas para e-commerce en Colombia. Tu misión es identificar con MÁXIMA PRECISIÓN la marca y el modelo exacto del calzado. +⚠️ REGLA CRÍTICA DE CERO PROVEEDORES / CERO GRUPOS (ESTRICTO): +- NI EN EL TÍTULO ("name") NI EN NINGÚN TEXTO COMERCIAL DE LA PUBLICACIÓN SE DEBE INCLUIR EL NOMBRE DEL GRUPO DE WHATSAPP, CANAL, PROVEEDOR, BODEGA MAYORISTA, DISTRIBUIDOR O REMITENTE. +- El campo "name" debe ser ÚNICAMENTE el modelo limpio y comercial del calzado (ej: "Adidas Samba OG Blanco Negro", "Nike Air Force 1 07 All White", "New Balance 9060 Sea Salt", "On Cloudmonster Triple Black", "Puma Palermo Special Verde"). +- Si el mensaje contiene nombres de grupos o proveedores (${forbiddenClause} o palabras como "Bodega", "Mayorista", "Distribuidora", "Proveedor", "Jovic", "CC Mío", "Team Emporio", etc.), DEBES OMITIRLOS Y ELIMINARLOS POR COMPLETO DEL TÍTULO Y DE TODO TEXTO. +- Si la zapatilla no tiene modelo exacto identificable, asigna un nombre comercial elegante (ej: "Sneakers Urban Edition"), pero JAMÁS inventes ni coloques el nombre de la bodega o del grupo. + ${ imagePayload ? `🔴 INSTRUCCIÓN DE VISIÓN CRÍTICA: @@ -601,7 +650,7 @@ Observa con atención la IMAGEN del calzado y detecta los logos visuales de la m Extrae los siguientes campos en formato JSON estricto: - "brand": La marca del calzado ("Adidas", "Nike", "Jordan", "New Balance", "On Running", "Puma", "Asics", "Vans", "Converse", "Salomon", "Hoka", "Balenciaga", "Alexander McQueen", "Golden Goose", "Crocs", "Under Armour", "Lacoste", "Timberland", "Diesel", "Oakley", "Fila", "Reebok", "Bape", "Louis Vuitton", "Dior", "Gucci", "Prada", "Autry", "Veja"). Si no puedes identificar ninguna marca con certeza, responde "Urbano / Variado". ¡NUNCA COLOQUES NIKE POR DEFECTO SI EL LOGO O EL MODELO NO ES NIKE! -- "name": Nombre comercial completo y limpio del modelo (ej: "Adidas Samba OG Blanco Negro", "New Balance 9060 Sea Salt", "On Cloudmonster All Black", "Puma Palermo Special Verde", "Nike Dunk Low Retro Panda", "Jordan 4 Retro Military Black", "Asics Gel-Kayano 14 Silver", "Vans Knu Skool Black White", "Salomon XT-6 Gore-Tex"). +- "name": Nombre comercial completo y limpio del modelo (ej: "Adidas Samba OG Blanco Negro", "New Balance 9060 Sea Salt", "On Cloudmonster All Black", "Puma Palermo Special Verde", "Nike Dunk Low Retro Panda", "Jordan 4 Retro Military Black", "Asics Gel-Kayano 14 Silver", "Vans Knu Skool Black White", "Salomon XT-6 Gore-Tex"). ¡CERO NOMBRES DE GRUPOS O PROVEEDORES! - "colorName": Nombre descriptivo del color o colorway (ej: "Blanco / Negro (Panda)", "Sea Salt / Gris", "Triple Black", "Verde / Goma", "Military Black"). - "sizes": Array de números de tallas disponibles (ej: ["37", "38", "39", "40", "41", "42", "43"]). - "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 75000, 80000, 85000, 110000). Analiza símbolos como 💸, 💰, $, 75k, 85.000, etc. @@ -670,11 +719,12 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto brand = secondaryCheck.brand; } - const cleanName = + const rawExtractedName = parsed.name || secondaryCheck.modelName || - cleanProductName(text, brand); + cleanProductName(text, brand, extraForbidden); + const cleanName = sanitizePublicationTitle(rawExtractedName, extraForbidden, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { diff --git a/dripzone-app/src/lib/model-grouper.ts b/dripzone-app/src/lib/model-grouper.ts index cb0af8e..aa0af52 100644 --- a/dripzone-app/src/lib/model-grouper.ts +++ b/dripzone-app/src/lib/model-grouper.ts @@ -1,6 +1,7 @@ import { Product, ProductColorVariant } from '@/types'; import { generateAttractiveProductCopy } from './attractive-copy'; import { getProductSocialProof } from './social-proof'; +import { sanitizePublicationTitle, sanitizePublicationText } from './sanitizer'; /** * Normaliza el nombre del modelo para identificar automáticamente productos del mismo modelo @@ -35,13 +36,16 @@ export function groupProductsByModel(products: Product[]): Product[] { for (const prod of products) { const brand = prod.brand || 'Sneakers'; - const baseModelName = prod.modelName || prod.name; + const cleanName = sanitizePublicationTitle(prod.name, [prod.supplierName], brand); + const baseModelName = sanitizePublicationTitle(prod.modelName || cleanName, [prod.supplierName], brand); const modelKey = prod.isIndependent ? `indep-${prod.id}` : prod.customModelKey || normalizeModelKey(baseModelName, brand); - - + const cleanDesc = sanitizePublicationText( + prod.description || generateAttractiveProductCopy({ name: cleanName, brand, retailPrice: prod.retailPrice, forbiddenTerms: [prod.supplierName] }), + [prod.supplierName] + ); if (!modelMap.has(modelKey)) { // Inicializar el primer producto para este modelo @@ -63,9 +67,10 @@ export function groupProductsByModel(products: Product[]): Product[] { modelMap.set(modelKey, { ...prod, + name: cleanName, modelName: baseModelName, colors, - description: prod.description || generateAttractiveProductCopy(prod), + description: cleanDesc, rating: prod.rating || proof.rating, reviewsCount: prod.reviewsCount || proof.reviewsCount, salesCount: prod.salesCount || proof.salesCount, diff --git a/dripzone-app/src/lib/sanitizer.ts b/dripzone-app/src/lib/sanitizer.ts new file mode 100644 index 0000000..7e6d51d --- /dev/null +++ b/dripzone-app/src/lib/sanitizer.ts @@ -0,0 +1,216 @@ +/** + * Módulo de Sanitización para Títulos y Textos de Publicaciones + * Garantiza estrictamente que NI en el título NI en el texto de la publicación + * se incluya el nombre del grupo de WhatsApp, canal, proveedor o términos mayoristas. + */ + +// Términos y palabras clave prohibidas por defecto (grupos, bodegas, proveedores y mayoristas) +export const DEFAULT_FORBIDDEN_TERMS = [ + 'difusión jovic store colombia', + 'difusion jovic store colombia', + 'difusion jovic store', + 'difusión jovic store', + 'difusion jovic', + 'difusión jovic', + 'jovic store', + 'exclusive cc mío 3b', + 'exclusive cc mio 3b', + 'cc mío 3b', + 'cc mio 3b', + 'cc mio', + 'cc mío', + 'bodega central mayorista', + 'bodega central', + 'bodega mayorista', + 'proveedor mayorista', + 'calzado mayorista', + 'mayorista cali', + 'mayoristas cali', + 'bodega cali', + 'canal mayorista', + 'grupo mayorista', + 'grupo whatsapp', + 'team emporio', + 'medios y prensa gob cauca', + 'medios y prensa', + 'notificaciones almeira', + 'almeira notificaciones', + 'san andresito', + 'dropshipping colombia', + 'bodegas aliadas', +]; + +// Regex para detectar patrones de prefijos de proveedor / grupo +const SUPPLIER_PREFIX_PATTERNS = [ + /(?:^|\n|\s*)(?:proveedor|bodega|distribuidor[a]?|mayorista[s]?|canal|grupo\s*(?:de\s*whatsapp)?)\s*[:\-–—]\s*([^\n,.]+)/gi, + /(?:^|\n|\s*)(?:de|por|remitente|fuente)\s*[:\-–—]\s*([^\n,.]+)/gi, +]; + +// Regex para eliminar palabras y conceptos de intermediarios en publicaciones comerciales +const GENERIC_FORBIDDEN_WORDS_REGEX = + /\b(proveedor(?:es)?|bodega(?:s)?(?:\s+mayorista)?|distribuidor(?:a)?(?:es)?|mayorista(?:s)?|canal(?:\s+mayorista)?|grupo(?:\s+de)?\s+whatsapp)\b/gi; + +/** + * Escapa caracteres especiales para usar en expresiones regulares + */ +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Limpia emojis y símbolos decorativos de nombres de grupos + */ +export function cleanGroupName(raw: string): string { + if (!raw) return ''; + return raw + .replace(/[💎🔥👑❤️‍🔥🤯🚨✨🌟⚡👟📦🏢📍🏷️💵💰💸💲]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Normaliza y compila lista de términos prohibidos + */ +function buildForbiddenList(extraForbidden: (string | undefined | null)[] = []): string[] { + const terms = new Set(); + + DEFAULT_FORBIDDEN_TERMS.forEach((t) => terms.add(t.toLowerCase().trim())); + + extraForbidden.forEach((item) => { + if (!item || typeof item !== 'string') return; + const clean = cleanGroupName(item).toLowerCase().trim(); + if (clean.length >= 3) { + terms.add(clean); + } + const rawLower = item.toLowerCase().trim(); + if (rawLower.length >= 3) { + terms.add(rawLower); + } + }); + + return Array.from(terms).filter((t) => t.length >= 3); +} + +/** + * Sanitiza el TÍTULO / NOMBRE comercial de la publicación + * - Elimina nombres de grupos de WhatsApp + * - Elimina nombres de proveedores o bodegas + * - Elimina menciones como "Bodega", "Mayorista", "Proveedor", etc. + * - Asegura que el producto tenga un título comercial atractivo y limpio + */ +export function sanitizePublicationTitle( + rawTitle: string, + extraForbidden: (string | undefined | null)[] = [], + fallbackBrand: string = 'Sneakers' +): string { + if (!rawTitle || typeof rawTitle !== 'string') { + return `${fallbackBrand} Urban Edition`; + } + + let cleaned = rawTitle.trim(); + + // 1. Quitar prefijos de proveedor (ej: "Proveedor: Nike Dunk" -> "Nike Dunk") + for (const pattern of SUPPLIER_PREFIX_PATTERNS) { + cleaned = cleaned.replace(pattern, '').trim(); + } + + // 2. Quitar nombres de grupos y proveedores específicos + const forbiddenList = buildForbiddenList(extraForbidden); + for (const term of forbiddenList) { + const termRegex = new RegExp(`(^|[\\s,;:\\-–—(]|\\[)${escapeRegExp(term)}([\\s,;:\\-–—)\\]]|$)`, 'gi'); + cleaned = cleaned.replace(termRegex, ' ').trim(); + } + + // 3. Quitar palabras genéricas de intermediarios + cleaned = cleaned.replace(GENERIC_FORBIDDEN_WORDS_REGEX, ' ').trim(); + + // 4. Limpiar emojis excesivos de inicio, códigos numéricos, precios pegados + cleaned = cleaned + .replace(/^[#*_\-•—–~:;,\s]+/, '') + .replace(/🔥|🔴|‼️|✨|🌸|👑|💎|🛍️|🌟|💸|💰|💵|💲|\*/g, '') + .replace(/\b\d{2,3}[\.,]\d{3}\b|\b\d{2,3}k\b|\$\s*\d+/gi, '') + .replace(/dama\s*y\s*caballero|caballero|dama|unisex|recarga|nueva\s*mercanc[ií]a|[uú]ltima\s*unidad/gi, '') + .replace(/\s+/g, ' ') + .trim(); + + // 5. Si quedó vacío o demasiado corto o numérico, asignar nombre por defecto limpio + if (cleaned.length < 3 || /^\d+$/.test(cleaned) || /^[-,.:;\s]+$/.test(cleaned)) { + return fallbackBrand && fallbackBrand !== 'Urbano / Variado' + ? `${fallbackBrand} Urban Edition` + : 'Zapatillas Urbanas Streetwear'; + } + + // 6. Formatear en Capital Case limpio + return cleaned + .split(' ') + .filter(Boolean) + .map((w) => { + // Conservar siglas reconocidas en mayúsculas completas (OG, SE, SB, TN, CL, RS-X, GT, etc.) + if (/^(OG|SE|SB|TN|CL|GT|RS-X|BB|LV|NB|XT-6|9060|550|530|1906R|2002R)$/i.test(w)) { + return w.toUpperCase(); + } + return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); + }) + .join(' '); +} + +/** + * Sanitiza el TEXTO / DESCRIPCIÓN de la publicación + * - Elimina menciones a grupos de WhatsApp, enlaces a grupos y canales + * - Elimina nombres de proveedores, bodegas o precios de compra mayorista + * - Elimina contactos directos del proveedor o advertencias dirigidas a revendedores + */ +export function sanitizePublicationText( + rawText: string, + extraForbidden: (string | undefined | null)[] = [] +): string { + if (!rawText || typeof rawText !== 'string') return ''; + + let cleaned = rawText; + + // 1. Quitar enlaces de invitaciones a grupos de WhatsApp + cleaned = cleaned.replace(/https?:\/\/chat\.whatsapp\.com\/[A-Za-z0-9]+/gi, ''); + + // 2. Quitar teléfonos típicos de proveedores mencionados en el texto + cleaned = cleaned.replace(/(?:tel[eé]fono|contacto|pedidos\s*proveedor|asesor|bodega)\s*[:\-–—]?\s*(?:\+?57\s*)?3\d{2}[\s.-]?\d{3}[\s.-]?\d{4}/gi, ''); + + // 3. Quitar nombres de grupos y proveedores específicos + const forbiddenList = buildForbiddenList(extraForbidden); + for (const term of forbiddenList) { + const termRegex = new RegExp(`(^|[\\s,;:\\-–—(]|\\[)${escapeRegExp(term)}([\\s,;:\\-–—)\\]]|$)`, 'gi'); + cleaned = cleaned.replace(termRegex, ' '); + } + + // 4. Quitar líneas que sean exclusivamente identificadores de proveedor o grupo + const lines = cleaned.split('\n'); + const cleanLines = lines.filter((line) => { + const lower = line.toLowerCase().trim(); + if ( + lower.startsWith('proveedor:') || + lower.startsWith('bodega:') || + lower.startsWith('canal:') || + lower.startsWith('grupo:') || + lower.includes('grupo mayorista') || + lower.includes('canal de difusión') || + lower.includes('canal de difusion') || + lower.includes('difusion jovic') || + lower.includes('cc mío 3b') + ) { + return false; + } + return true; + }); + + cleaned = cleanLines.join('\n'); + + // 5. Quitar palabras clave genéricas de intermediarios sueltas + cleaned = cleaned.replace(GENERIC_FORBIDDEN_WORDS_REGEX, ' '); + + // 6. Normalizar espaciado + cleaned = cleaned + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return cleaned; +} diff --git a/dripzone-app/src/lib/server-appwrite.ts b/dripzone-app/src/lib/server-appwrite.ts index ab6de07..59cef09 100644 --- a/dripzone-app/src/lib/server-appwrite.ts +++ b/dripzone-app/src/lib/server-appwrite.ts @@ -12,6 +12,7 @@ import { } from '@/types'; import { INITIAL_CITIES, INITIAL_PAYMENT_METHODS, INITIAL_SUPPLIERS } from './data-service'; import { DEFAULT_MESSAGE_TEMPLATES } from './templates-service'; +import { sanitizePublicationTitle, sanitizePublicationText } from './sanitizer'; const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1'; const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c'; @@ -520,19 +521,23 @@ export async function getProductsFromAppwrite(): Promise { if (d.colors) colors = typeof d.colors === 'string' ? JSON.parse(d.colors) : d.colors; } catch (_) {} + const brand = d.brand || 'Sneakers'; + const cleanName = sanitizePublicationTitle(d.name || 'Zapatillas', [d.supplierName], brand); + const cleanDesc = sanitizePublicationText(d.description || '', [d.supplierName]); + return { id: d.$id, - name: d.name || 'Zapatillas', - modelName: d.modelName || d.name, + name: cleanName, + modelName: cleanName, colorName: d.colorName || '', - brand: d.brand || 'Sneakers', + brand: brand, supplierPrice: d.supplierPrice || 90000, retailPrice: d.retailPrice || 125000, originalPrice: d.originalPrice || Math.round((d.retailPrice || 125000) * 1.35), sizes, images, colors, - description: d.description || '', + description: cleanDesc, category: d.category || 'Calzado / Sneakers', inStock: d.inStock !== false, isActive: d.isActive !== false, @@ -548,10 +553,14 @@ export async function getProductsFromAppwrite(): Promise { } export async function saveProductToAppwrite(product: Product): Promise { + const brand = product.brand || 'Sneakers'; + const cleanName = sanitizePublicationTitle(product.name, [product.supplierName], brand); + const cleanDesc = sanitizePublicationText(product.description || '', [product.supplierName]); + const payload = { - name: product.name, - modelName: product.modelName || product.name, - brand: product.brand || 'Sneakers', + name: cleanName, + modelName: cleanName, + brand: brand, colorName: product.colorName || '', supplierPrice: product.supplierPrice || 0, retailPrice: product.retailPrice || 0, @@ -559,7 +568,7 @@ export async function saveProductToAppwrite(product: Product): Promise sizes: JSON.stringify(product.sizes || []), images: JSON.stringify(product.images || []), colors: JSON.stringify(product.colors || []), - description: product.description || '', + description: cleanDesc, category: product.category || 'Sneakers', inStock: product.inStock !== false, isActive: product.isActive !== false, @@ -569,14 +578,14 @@ export async function saveProductToAppwrite(product: Product): Promise try { const doc = await serverDatabases.createDocument(DB_ID, 'products', product.id, payload); - return product; + return { ...product, name: cleanName, modelName: cleanName, description: cleanDesc }; } catch (e: any) { if (e.code === 409) { try { await serverDatabases.updateDocument(DB_ID, 'products', product.id, payload); } catch (_) {} } - return product; + return { ...product, name: cleanName, modelName: cleanName, description: cleanDesc }; } } diff --git a/scripts/optimize_storage_engine.py b/scripts/optimize_storage_engine.py new file mode 100644 index 0000000..423051b --- /dev/null +++ b/scripts/optimize_storage_engine.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +import os +import sys +import time +import shutil +import hashlib +import subprocess +from concurrent.futures import ProcessPoolExecutor, as_completed +from PIL import Image, ImageOps + +BUCKET_PATH = "/var/lib/docker/volumes/sss6crntvrcs54qbhvhy5728_appwrite-uploads/_data/app-69b84df5003bd8957c5c/media_bucket" +CACHE_PATH = "/var/lib/docker/volumes/sss6crntvrcs54qbhvhy5728_appwrite-cache/_data/app-69b84df5003bd8957c5c" +STAGING_DIR = "/tmp/img_opt_staging" +MARIADB_CONTAINER = "appwrite-mariadb-sss6crntvrcs54qbhvhy5728" +DB_USER = "sYoHWwl0DGvEyt3s" +DB_PASS = "sn9YkZqoLV06CQxAy2qsKi36S7POVShT" +DB_NAME = "appwrite" +TABLE_NAME = "_1_bucket_1" + +MAX_DIMENSION = 1920 +JPEG_QUALITY = 82 + +def optimize_single_file(filename): + """ + Worker function to optimize a single image file. + Returns: (status, filename, file_id, orig_size, new_size, new_md5) + """ + orig_path = os.path.join(BUCKET_PATH, filename) + file_id, ext = os.path.splitext(filename) + ext_lower = ext.lower() + + if ext_lower not in ('.jpg', '.jpeg', '.png', '.webp'): + return ('SKIPPED_NOT_IMAGE', filename, file_id, 0, 0, '') + + try: + orig_size = os.path.getsize(orig_path) + except Exception: + return ('ERROR_STAT', filename, file_id, 0, 0, '') + + if orig_size < 1024: # Tiny icon, less than 1KB + return ('SKIPPED_TOO_SMALL', filename, file_id, orig_size, orig_size, '') + + staging_path = os.path.join(STAGING_DIR, f"{filename}.tmp") + + try: + with Image.open(orig_path) as img: + # Transpose orientation according to EXIF + try: + img = ImageOps.exif_transpose(img) + except Exception: + pass + + w, h = img.size + needs_resize = max(w, h) > MAX_DIMENSION + + if needs_resize: + if w > h: + new_w = MAX_DIMENSION + new_h = int(h * (MAX_DIMENSION / w)) + else: + new_h = MAX_DIMENSION + new_w = int(w * (MAX_DIMENSION / h)) + img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) + + # Save optimized + if ext_lower in ('.jpg', '.jpeg'): + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + img.save( + staging_path, + format="JPEG", + quality=JPEG_QUALITY, + optimize=True, + progressive=True + ) + elif ext_lower == '.png': + img.save( + staging_path, + format="PNG", + optimize=True + ) + elif ext_lower == '.webp': + img.save( + staging_path, + format="WEBP", + quality=JPEG_QUALITY, + method=6 + ) + + new_size = os.path.getsize(staging_path) + + # Only replace if we actually saved space + if new_size < orig_size: + # Calculate new MD5 + hasher = hashlib.md5() + with open(staging_path, 'rb') as f: + while chunk := f.read(65536): + hasher.update(chunk) + new_md5 = hasher.hexdigest() + + # Replace original atomically + shutil.move(staging_path, orig_path) + os.chmod(orig_path, 0o644) + shutil.chown(orig_path, user="root", group="root") + + return ('OPTIMIZED', filename, file_id, orig_size, new_size, new_md5) + else: + if os.path.exists(staging_path): + os.remove(staging_path) + return ('SKIPPED_NO_SAVINGS', filename, file_id, orig_size, orig_size, '') + + except Exception as e: + if os.path.exists(staging_path): + try: + os.remove(staging_path) + except Exception: + pass + return ('ERROR_PROCESS', filename, file_id, orig_size, orig_size, str(e)) + + +def apply_db_updates(updates): + """ + Applies updates in batches to MariaDB table _1_bucket_1. + updates is a list of (file_id, new_size, new_md5) + """ + if not updates: + return 0 + + print(f"\n[DB] Aplicando {len(updates)} actualizaciones en la base de datos MariaDB...") + batch_size = 500 + total_updated = 0 + + for i in range(0, len(updates), batch_size): + chunk = updates[i:i + batch_size] + sql_lines = ["START TRANSACTION;"] + for file_id, new_size, new_md5 in chunk: + sql_lines.append( + f"UPDATE {TABLE_NAME} SET sizeOriginal = {new_size}, sizeActual = {new_size}, signature = '{new_md5}' WHERE _uid = '{file_id}';" + ) + sql_lines.append("COMMIT;") + + sql_script = "\n".join(sql_lines) + tmp_sql_file = f"/tmp/update_batch_{i}.sql" + with open(tmp_sql_file, "w", encoding="utf-8") as f: + f.write(sql_script) + + # Pipe sql into docker exec + cat_cmd = f"cat {tmp_sql_file} | docker exec -i {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME}" + res = subprocess.run(cat_cmd, shell=True, capture_output=True, text=True) + + if os.path.exists(tmp_sql_file): + os.remove(tmp_sql_file) + + if res.returncode != 0: + print(f" [ERROR] Lote {i}-{i+len(chunk)} falló: {res.stderr}") + else: + total_updated += len(chunk) + print(f" ✓ Lote actualizado: {total_updated} / {len(updates)} registros") + + return total_updated + + +def main(): + print("=" * 60) + print("MOTOR DE OPTIMIZACIÓN EN MASA DE ALMACENAMIENTO (APPWRITE)") + print("=" * 60) + + if not os.path.exists(BUCKET_PATH): + print(f"[ERROR] No se encontró el bucket en: {BUCKET_PATH}") + sys.exit(1) + + os.makedirs(STAGING_DIR, exist_ok=True) + + files = sorted(os.listdir(BUCKET_PATH)) + total_files = len(files) + print(f"[INFO] Archivos encontrados en el almacenamiento: {total_files}") + + start_time = time.time() + workers = min(10, os.cpu_count() or 4) + print(f"[INFO] Iniciando procesamiento paralelo con {workers} hilos de CPU...\n") + + optimized_count = 0 + skipped_no_savings = 0 + skipped_other = 0 + error_count = 0 + + total_orig_bytes = 0 + total_new_bytes = 0 + db_updates = [] + + # Process in parallel + with ProcessPoolExecutor(max_workers=workers) as executor: + futures = {executor.submit(optimize_single_file, f): f for f in files} + + done_count = 0 + for future in as_completed(futures): + done_count += 1 + status, filename, file_id, orig_sz, new_sz, new_md5 = future.result() + + if status == 'OPTIMIZED': + optimized_count += 1 + total_orig_bytes += orig_sz + total_new_bytes += new_sz + db_updates.append((file_id, new_sz, new_md5)) + elif status == 'SKIPPED_NO_SAVINGS': + skipped_no_savings += 1 + total_orig_bytes += orig_sz + total_new_bytes += orig_sz + elif status.startswith('SKIPPED'): + skipped_other += 1 + total_orig_bytes += orig_sz + total_new_bytes += orig_sz + else: + error_count += 1 + total_orig_bytes += orig_sz + total_new_bytes += orig_sz + + if done_count % 500 == 0 or done_count == total_files: + elapsed = time.time() - start_time + rate = done_count / elapsed if elapsed > 0 else 0 + saved_mb = (total_orig_bytes - total_new_bytes) / (1024 * 1024) + print(f"[{done_count}/{total_files}] ({done_count*100/total_files:.1f}%) | " + f"Optimizadas: {optimized_count} | Ahorro actual: {saved_mb:.1f} MB | {rate:.1f} imgs/seg") + + # Clean staging dir + shutil.rmtree(STAGING_DIR, ignore_errors=True) + + # Apply database updates + if db_updates: + apply_db_updates(db_updates) + + # Clear Appwrite preview cache so new previews reflect optimized images + print("\n[CACHE] Limpiando caché de previsualizaciones de Appwrite...") + if os.path.exists(CACHE_PATH): + try: + cache_count = len(os.listdir(CACHE_PATH)) + for item in os.listdir(CACHE_PATH): + item_path = os.path.join(CACHE_PATH, item) + if os.path.isfile(item_path) or os.path.islink(item_path): + os.unlink(item_path) + elif os.path.isdir(item_path): + shutil.rmtree(item_path) + print(f" ✓ {cache_count} archivos de caché eliminados exitosamente.") + except Exception as e: + print(f" [AVISO] No se pudo vaciar la caché por completo: {e}") + + # Summary + elapsed_total = time.time() - start_time + total_orig_mb = total_orig_bytes / (1024 * 1024) + total_new_mb = total_new_bytes / (1024 * 1024) + total_saved_mb = total_orig_mb - total_new_mb + pct_savings = (total_saved_mb / total_orig_mb * 100) if total_orig_mb > 0 else 0 + + print("\n" + "=" * 60) + print("RESUMEN DE OPTIMIZACIÓN COMPLETADO") + print("=" * 60) + print(f"Total archivos escaneados: {total_files}") + print(f"Archivos optimizados: {optimized_count}") + print(f"Archivos sin ganancia (skip): {skipped_no_savings}") + print(f"Otros archivos (videos/etc): {skipped_other}") + print(f"Errores / omitidos: {error_count}") + print(f"Tiempo total: {elapsed_total:.1f} segundos ({elapsed_total/60:.2f} minutos)") + print("-" * 60) + print(f"Tamaño original total: {total_orig_mb:.2f} MB ({total_orig_mb/1024:.2f} GB)") + print(f"Tamaño final optimizado: {total_new_mb:.2f} MB ({total_new_mb/1024:.2f} GB)") + print(f"Ahorro neto de espacio: {total_saved_mb:.2f} MB ({total_saved_mb/1024:.2f} GB)") + print(f"Porcentaje de reducción: {pct_savings:.2f}%") + print("=" * 60) + + +if __name__ == '__main__': + main() diff --git a/scripts/query_vps_db.py b/scripts/query_vps_db.py new file mode 100644 index 0000000..50c2e1a --- /dev/null +++ b/scripts/query_vps_db.py @@ -0,0 +1,26 @@ +import paramiko +import sys +import io + +if sys.platform == 'win32': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + +HOST = "161.97.93.156" +USER = "carlos" +PASSWORD = "Innova1978+*?" + +client = paramiko.SSHClient() +client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30) + +sql = sys.argv[1] if len(sys.argv) > 1 else 'SHOW TABLES;' +cmd = f'docker exec -i appwrite-mariadb-sss6crntvrcs54qbhvhy5728 mysql -u sYoHWwl0DGvEyt3s -psn9YkZqoLV06CQxAy2qsKi36S7POVShT appwrite -e "{sql}"' + +stdin, stdout, stderr = client.exec_command(cmd) +for line in stdout: + print(line.strip()) +for line in stderr: + print("[ERR]:", line.strip()) + +client.close() diff --git a/scripts/run_remote_script.py b/scripts/run_remote_script.py new file mode 100644 index 0000000..d645f98 --- /dev/null +++ b/scripts/run_remote_script.py @@ -0,0 +1,40 @@ +import paramiko +import sys +import io + +if sys.platform == 'win32': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + +HOST = "161.97.93.156" +USER = "carlos" +PASSWORD = "Innova1978+*?" + +def upload_and_execute(local_file, remote_file="/tmp/remote_script.py"): + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30) + + sftp = client.open_sftp() + sftp.put(local_file, remote_file) + sftp.close() + + cmd = f'echo "{PASSWORD}" | sudo -S python3 {remote_file}' + stdin, stdout, stderr = client.exec_command(cmd) + + out = stdout.read().decode('utf-8', errors='replace') + err = stderr.read().decode('utf-8', errors='replace') + + if out: + print(out, end='') + if err: + # filter sudo password prompt + filtered = [l for l in err.splitlines() if '[sudo] password' not in l] + if filtered: + print("[ERR]:\n" + "\n".join(filtered)) + + client.close() + +if __name__ == '__main__': + local = sys.argv[1] if len(sys.argv) > 1 else 'scripts/test_single_optimize.py' + upload_and_execute(local) diff --git a/scripts/run_vps.py b/scripts/run_vps.py new file mode 100644 index 0000000..38fab4d --- /dev/null +++ b/scripts/run_vps.py @@ -0,0 +1,28 @@ +import paramiko +import sys +import io + +if sys.platform == 'win32': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + +HOST = "161.97.93.156" +USER = "carlos" +PASSWORD = "Innova1978+*?" + +client = paramiko.SSHClient() +client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30) + +cmd = ' '.join(sys.argv[1:]) if len(sys.argv) > 1 else 'uptime' +stdin, stdout, stderr = client.exec_command(cmd) + +out = stdout.read().decode('utf-8', errors='replace') +err = stderr.read().decode('utf-8', errors='replace') + +if out: + print(out, end='') +if err: + print("[ERR]:", err, end='') + +client.close() diff --git a/scripts/verify_appwrite_health.py b/scripts/verify_appwrite_health.py new file mode 100644 index 0000000..5519acb --- /dev/null +++ b/scripts/verify_appwrite_health.py @@ -0,0 +1,55 @@ +import urllib.request +import json +import random + +ENDPOINT = "https://appwrite.almeira.com.co/v1" +PROJECT_ID = "69b84df5003bd8957c5c" +BUCKET_ID = "media_bucket" +API_KEY = "standard_cce3cdb866ea6433888d40cedd24dc45987372308fdc3dd0697de08648c0e5c7e04b02c3bcee271403fd656b00a5820f04c08b51631284a6e8a3582d8aed2aa33234f3c29cc777e9b162eb54810b789251b0d006a9a3b081ebc2da53b9baa94614b124b3508ad849fd9a4187cc6094f506d4b57f409b69d9562a74df594f3ec3" + +def check_system(): + print("=== VERIFICACIÓN DEL SISTEMA APPWRITE TRAS OPTIMIZACIÓN ===") + + # 1. List files via Appwrite API + list_url = f"{ENDPOINT}/storage/buckets/{BUCKET_ID}/files?queries[0]=limit(25)" + req = urllib.request.Request(list_url, headers={ + "X-Appwrite-Project": PROJECT_ID, + "X-Appwrite-Key": API_KEY + }) + + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read().decode('utf-8')) + files = data.get('files', []) + total = data.get('total', 0) + print(f"✓ Total archivos reportados por Appwrite API: {total}") + print(f"✓ Muestra obtenida: {len(files)} archivos.") + + # 2. Test HTTP download/view of 5 sample images + print("\n--- Verificando descarga HTTP pública de imágenes ---") + sample_files = random.sample(files, min(5, len(files))) + for f in sample_files: + fid = f['$id'] + name = f['name'] + mime = f['mimeType'] + size = f['sizeOriginal'] + + view_url = f"{ENDPOINT}/storage/buckets/{BUCKET_ID}/files/{fid}/view?project={PROJECT_ID}" + v_req = urllib.request.Request(view_url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(v_req) as v_resp: + status = v_resp.status + c_len = v_resp.headers.get("Content-Length") + c_type = v_resp.headers.get("Content-Type") + print(f" ✓ [{status}] ID: {fid} | {name[:30]} | {int(c_len):,} bytes | Type: {c_type}") + + # 3. Test Preview endpoint (on-the-fly transformation) + print("\n--- Verificando endpoint de previsualización dinámica (/preview) ---") + test_f = sample_files[0] + prev_url = f"{ENDPOINT}/storage/buckets/{BUCKET_ID}/files/{test_f['$id']}/preview?project={PROJECT_ID}&width=300&output=webp" + p_req = urllib.request.Request(prev_url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(p_req) as p_resp: + print(f" ✓ [{p_resp.status}] Preview dinámico WebP: {p_resp.headers.get('Content-Type')}, tamaño: {p_resp.headers.get('Content-Length')} bytes") + + print("\n✅ TODAS LAS PRUEBAS DE INTEGRIDAD Y DISPONIBILIDAD PASARON EXITOSAMENTE.") + +if __name__ == '__main__': + check_system() diff --git a/scripts/verify_single_apply.py b/scripts/verify_single_apply.py new file mode 100644 index 0000000..89fe9c7 --- /dev/null +++ b/scripts/verify_single_apply.py @@ -0,0 +1,60 @@ +import os +import shutil +import subprocess +import urllib.request + +BUCKET_PATH = "/var/lib/docker/volumes/sss6crntvrcs54qbhvhy5728_appwrite-uploads/_data/app-69b84df5003bd8957c5c/media_bucket" +FILE_ID = "6a37df0e00089708650b" +TEST_FILE = f"{FILE_ID}.jpg" +target_file = os.path.join(BUCKET_PATH, TEST_FILE) +backup_file = f"{target_file}.bak" +tmp_opt = f"/tmp/{TEST_FILE}" + +if not os.path.exists(tmp_opt): + print("Error: /tmp optimized file not found") + exit(1) + +new_size = os.path.getsize(tmp_opt) +new_md5 = "e0a0fba38f6245a623eefc1a001852cc" + +print("1. Creating backup of original...") +shutil.copy2(target_file, backup_file) + +print("2. Replacing target file with optimized file...") +shutil.copy2(tmp_opt, target_file) + +# Ensure proper permissions and ownership +os.chmod(target_file, 0o644) +shutil.chown(target_file, user="root", group="root") + +print("3. Updating MariaDB record...") +sql = f"UPDATE _1_bucket_1 SET sizeOriginal = {new_size}, sizeActual = {new_size}, signature = '{new_md5}' WHERE _uid = '{FILE_ID}';" +cmd = [ + "docker", "exec", "-i", "appwrite-mariadb-sss6crntvrcs54qbhvhy5728", + "mysql", "-u", "sYoHWwl0DGvEyt3s", "-psn9YkZqoLV06CQxAy2qsKi36S7POVShT", "appwrite", + "-e", sql +] +res = subprocess.run(cmd, capture_output=True, text=True) +if res.returncode != 0: + print("MariaDB update error:", res.stderr) +else: + print("MariaDB record updated successfully.") + +print("4. Testing HTTP endpoint from Appwrite...") +url = f"https://appwrite.almeira.com.co/v1/storage/buckets/media_bucket/files/{FILE_ID}/view?project=69b84df5003bd8957c5c" +req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) +try: + with urllib.request.urlopen(req) as response: + status = response.status + content_length = response.headers.get("Content-Length") + content_type = response.headers.get("Content-Type") + print(f"HTTP Status: {status}") + print(f"Content-Length: {content_length} bytes (Matches optimized size: {int(content_length) == new_size})") + print(f"Content-Type: {content_type}") + print("\nSUCCESS! Appwrite is serving the optimized file perfectly!") +except Exception as e: + print("HTTP Request failed:", e) + +# Clean up backup +if os.path.exists(backup_file): + os.remove(backup_file) diff --git a/scripts/vps_info.py b/scripts/vps_info.py new file mode 100644 index 0000000..49bb302 --- /dev/null +++ b/scripts/vps_info.py @@ -0,0 +1,9 @@ +import os +print(f"CPU Count: {os.cpu_count()}") + +cache_path = "/var/lib/docker/volumes/sss6crntvrcs54qbhvhy5728_appwrite-cache/_data/app-69b84df5003bd8957c5c" +if os.path.exists(cache_path): + cache_files = os.listdir(cache_path) + print(f"Cache files count: {len(cache_files)}") +else: + print(f"Cache path does not exist: {cache_path}") diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts index 8aa86d8..735ea3f 100644 --- a/src/lib/gemini.ts +++ b/src/lib/gemini.ts @@ -33,6 +33,7 @@ function fallbackRuleBasedParser( let rawTitle = lines[0] || 'Nueva Publicación'; rawTitle = rawTitle.replace(/^[#*_\-•]+\s*/, '').replace(/[*_#]/g, '').trim(); + rawTitle = rawTitle.replace(/\b(proveedor(?:es)?|bodega(?:s)?|mayorista(?:s)?|distribuidor(?:a)?|canal(?:\s+mayorista)?|grupo(?:\s+de)?\s+whatsapp)\b/gi, '').trim(); if (rawTitle.length > 70) { rawTitle = rawTitle.substring(0, 67) + '...'; } @@ -95,14 +96,17 @@ export async function processWhatsAppMessage( Eres un asistente editorial experto y editor gráfico de Almeira Web. Analiza los mensajes y recursos multimedia recibidos desde WhatsApp para estructurarlos como publicaciones del portal. ${explicitTypeDirective} + REGLA CRÍTICA OBLIGATORIA (CERO NOMBRES DE GRUPO / PROVEEDOR): + Ni en el título ('titulo') ni en el texto o contenido ('contenido', 'seo_descripcion') de la publicación se debe incluir el nombre del grupo de WhatsApp ni el proveedor o remitente del producto o servicio. El título y el texto deben enfocarse en el hecho noticioso o producto sin mencionar nombres de grupos, canales de difusión ni proveedores mayoristas. + Debes estructurar el mensaje en base a lo siguiente: - 1. titulo: Asigna un título claro, profesional y conciso (máx. 70 caracteres). + 1. titulo: Asigna un título claro, profesional y conciso (máx. 70 caracteres). ¡Cero nombres de grupos o proveedores! 2. tipo: Determina la categoría exacta ${explicitType ? `(Usa obligatoriamente "${explicitType}")` : ''}: - "noticia": Noticias generales, política, actualidad, comunicados y reportajes (dimensiones recomendadas de imagen: 640x360 px, relación 16:9). - "evento": Invitaciones a actividades, jornadas, ferias, foros, congresos, festivales o convocatorias con fecha (dimensiones de imagen: 640x640 px, relación 1:1). - "aviso": Publicidad comercial, clasificados, ofertas de empleo, compra/venta, servicios profesionales, comunicados comunitarios (dimensiones de imagen: 640x640 px, relación 1:1). 3. seo_descripcion: Un resumen conciso de máximo 150 caracteres optimizado para redes y motores de búsqueda. - 4. contenido: El texto detallado de la publicación en HTML válido y limpio (usa

, ,

    /
  • si es apropiado), preservando nombres, fechas, contactos y enlaces originales. + 4. contenido: El texto detallado de la publicación en HTML válido y limpio (usa

    , ,

      /
    • si es apropiado), preservando nombres, fechas, contactos y enlaces originales, pero sin incluir nombres de grupos de WhatsApp o proveedores del producto. 5. fecha_vencimiento: Si el texto indica fecha límite del evento o del aviso (Ej. "hasta el 30 de noviembre", o "el próximo sábado"), devuélvela en formato YYYY-MM-DD. Si no aplica o es una noticia general, pasa null. 6. selected_image_index: Si se proporcionaron múltiples imágenes candidatas (#1, #2, #3...), selecciona el índice base 0 de la imagen MÁS ADECUADA y de mayor calidad periodística/visual para ser la IMAGEN DESTACADA (la más nítida, bien compuesta, representativa del tema central y sin recortes desfavorables). Si solo hay 1 imagen, devuelve 0. Si no hay imágenes, pasa null. 7. image_selection_reason: Breve explicación (máximo 1 línea) del motivo por el cual esa imagen es la más adecuada para portada. diff --git a/zapply-app/src/lib/attractive-copy.ts b/zapply-app/src/lib/attractive-copy.ts index 846c655..48938e9 100644 --- a/zapply-app/src/lib/attractive-copy.ts +++ b/zapply-app/src/lib/attractive-copy.ts @@ -1,7 +1,12 @@ +import { sanitizePublicationTitle, sanitizePublicationText } from './sanitizer'; + /** * Generador de Textos Comerciales Persuasivos y Atractivos para Calzado Deportivo * Produce descripciones llamativas con llamados a la acción (CTA), beneficios de calidad G5/1.1, * garantía de entrega y estética street-wear colombiana para maximizar conversiones. + * + * REGLA ESTRICTA: NI en el título NI en el texto de la publicación se debe incluir el nombre + * del grupo de WhatsApp ni el proveedor del producto. */ export function generateAttractiveProductCopy(product: { @@ -10,21 +15,26 @@ export function generateAttractiveProductCopy(product: { category?: string; supplierPrice?: number; retailPrice?: number; + forbiddenTerms?: (string | undefined | null)[]; }): string { const brand = product.brand || 'Sneakers'; - const name = product.name || 'Zapatillas Exclusivas'; + const cleanName = sanitizePublicationTitle( + product.name || 'Zapatillas Exclusivas', + product.forbiddenTerms, + brand + ); const hooks = [ - `🔥 ¡Lleva tu outfit al siguiente nivel con las icónicas ${name}! Diseñadas para quienes no negocian entre estilo de alto impacto y máxima comodidad para el día a día.`, - `⚡ Las ${name} son el drop definitivo que tu colección estaba esperando. Silueta imbatible, acabados prémium y una estética urbana que no pasa desapercibida.`, - `💎 Edición Prémium ${brand}: ${name}. Fabricadas con materiales de máxima durabilidad, textura prémium y una combinación de colores pensada para resaltar en cualquier ocasión.`, - `🚀 Dale un upgrade a tus pasos con las exclusivas ${name}. Comodidad extrema desde el primer uso, amortiguación reactiva y estilo 100% garantizado.`, + `🔥 ¡Lleva tu outfit al siguiente nivel con las icónicas ${cleanName}! Diseñadas para quienes no negocian entre estilo de alto impacto y máxima comodidad para el día a día.`, + `⚡ Las ${cleanName} son el drop definitivo que tu colección estaba esperando. Silueta imbatible, acabados prémium y una estética urbana que no pasa desapercibida.`, + `💎 Edición Prémium ${brand}: ${cleanName}. Fabricadas con materiales de máxima durabilidad, textura prémium y una combinación de colores pensada para resaltar en cualquier ocasión.`, + `🚀 Dale un upgrade a tus pasos con las exclusivas ${cleanName}. Comodidad extrema desde el primer uso, amortiguación reactiva y estilo 100% garantizado.`, ]; - const seed = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + const seed = cleanName.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); const selectedHook = hooks[Math.abs(seed) % hooks.length]; - return `${selectedHook} + const rawCopy = `${selectedHook} ✨ *¿Por qué todos las eligen?* • 💎 *Calidad G5 / 1.1 Prémium:* Materiales de alto rendimiento, costuras reforzadas y terminaciones impecables. @@ -33,5 +43,7 @@ export function generateAttractiveProductCopy(product: { • 💵 *Pago 100% Contraentrega:* Realizas tu pedido con total tranquilidad y pagas al recibir en tu puerta. • 🛡️ *Garantía Directa:* Revisas tu producto y verificas tu talla con total respaldo. -⚠️ *Disponibilidad Limitada:* Pocas unidades disponibles por talla en bodega nacional. ¡Asegura tu par ahora antes de que se agoten!`; +⚠️ *Disponibilidad Limitada:* Pocas unidades disponibles por talla en inventario nacional. ¡Asegura tu par ahora antes de que se agoten!`; + + return sanitizePublicationText(rawCopy, product.forbiddenTerms); } diff --git a/zapply-app/src/lib/gemini-parser.ts b/zapply-app/src/lib/gemini-parser.ts index 9d8d008..f4c88c0 100644 --- a/zapply-app/src/lib/gemini-parser.ts +++ b/zapply-app/src/lib/gemini-parser.ts @@ -3,6 +3,7 @@ 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 @@ -108,9 +109,30 @@ export function extractSizes(rawText: string): string[] { /** * Limpia y formatea el nombre del producto desde el mensaje del proveedor */ -export function cleanProductName(text: string, detectedBrand: string): string { +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, '') @@ -120,17 +142,22 @@ export function cleanProductName(text: string, detectedBrand: string): string { .replace(/\s+/g, ' ') .trim(); - if (clean.length >= 3 && !/^\d+$/.test(clean) && !/^\d+[\.,]\d+$/.test(clean)) { - return clean - .split(' ') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) - .join(' '); + const sanitized = sanitizePublicationTitle(clean, extraForbidden, detectedBrand); + if (sanitized.length >= 3 && !/^\d+$/.test(sanitized) && !/^\d+[\.,]\d+$/.test(sanitized)) { + return sanitized; } } - return `${detectedBrand} Urban Edition`; + return sanitizePublicationTitle( + `${detectedBrand} Urban Edition`, + extraForbidden, + detectedBrand + ); } -export async function parseSupplierMessage(text: string): Promise { +export async function parseSupplierMessage( + text: string, + extraForbidden: (string | undefined | null)[] = [] +): Promise { const apiKey = process.env.GEMINI_API_KEY; const fallbackExtract = (): ExtractedProductData => { @@ -146,7 +173,8 @@ export async function parseSupplierMessage(text: string): Promise(); + DEFAULT_FORBIDDEN_TERMS.forEach((t) => terms.add(t.toLowerCase().trim())); + + extraForbidden.forEach((item) => { + if (!item || typeof item !== 'string') return; + const clean = cleanGroupName(item).toLowerCase().trim(); + if (clean.length >= 3) { + terms.add(clean); + } + const rawLower = item.toLowerCase().trim(); + if (rawLower.length >= 3) { + terms.add(rawLower); + } + }); + + return Array.from(terms).filter((t) => t.length >= 3); +} + +export function sanitizePublicationTitle( + rawTitle: string, + extraForbidden: (string | undefined | null)[] = [], + fallbackBrand: string = 'Sneakers' +): string { + if (!rawTitle || typeof rawTitle !== 'string') { + return `${fallbackBrand} Urban Edition`; + } + + let cleaned = rawTitle.trim(); + + for (const pattern of SUPPLIER_PREFIX_PATTERNS) { + cleaned = cleaned.replace(pattern, '').trim(); + } + + const forbiddenList = buildForbiddenList(extraForbidden); + for (const term of forbiddenList) { + const termRegex = new RegExp(`(^|[\\s,;:\\-–—(]|\\[)${escapeRegExp(term)}([\\s,;:\\-–—)\\]]|$)`, 'gi'); + cleaned = cleaned.replace(termRegex, ' ').trim(); + } + + cleaned = cleaned.replace(GENERIC_FORBIDDEN_WORDS_REGEX, ' ').trim(); + + cleaned = cleaned + .replace(/^[#*_\-•—–~:;,\s]+/, '') + .replace(/🔥|🔴|‼️|✨|🌸|👑|💎|🛍️|🌟|💸|💰|💵|💲|\*/g, '') + .replace(/\b\d{2,3}[\.,]\d{3}\b|\b\d{2,3}k\b|\$\s*\d+/gi, '') + .replace(/dama\s*y\s*caballero|caballero|dama|unisex|recarga|nueva\s*mercanc[ií]a|[uú]ltima\s*unidad/gi, '') + .replace(/\s+/g, ' ') + .trim(); + + if (cleaned.length < 3 || /^\d+$/.test(cleaned) || /^[-,.:;\s]+$/.test(cleaned)) { + return fallbackBrand && fallbackBrand !== 'Urbano / Variado' + ? `${fallbackBrand} Urban Edition` + : 'Zapatillas Urbanas Streetwear'; + } + + return cleaned + .split(' ') + .filter(Boolean) + .map((w) => { + if (/^(OG|SE|SB|TN|CL|GT|RS-X|BB|LV|NB|XT-6|9060|550|530|1906R|2002R)$/i.test(w)) { + return w.toUpperCase(); + } + return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); + }) + .join(' '); +} + +export function sanitizePublicationText( + rawText: string, + extraForbidden: (string | undefined | null)[] = [] +): string { + if (!rawText || typeof rawText !== 'string') return ''; + + let cleaned = rawText; + + cleaned = cleaned.replace(/https?:\/\/chat\.whatsapp\.com\/[A-Za-z0-9]+/gi, ''); + cleaned = cleaned.replace(/(?:tel[eé]fono|contacto|pedidos\s*proveedor|asesor|bodega)\s*[:\-–—]?\s*(?:\+?57\s*)?3\d{2}[\s.-]?\d{3}[\s.-]?\d{4}/gi, ''); + + const forbiddenList = buildForbiddenList(extraForbidden); + for (const term of forbiddenList) { + const termRegex = new RegExp(`(^|[\\s,;:\\-–—(]|\\[)${escapeRegExp(term)}([\\s,;:\\-–—)\\]]|$)`, 'gi'); + cleaned = cleaned.replace(termRegex, ' '); + } + + const lines = cleaned.split('\n'); + const cleanLines = lines.filter((line) => { + const lower = line.toLowerCase().trim(); + if ( + lower.startsWith('proveedor:') || + lower.startsWith('bodega:') || + lower.startsWith('canal:') || + lower.startsWith('grupo:') || + lower.includes('grupo mayorista') || + lower.includes('canal de difusión') || + lower.includes('canal de difusion') || + lower.includes('difusion jovic') || + lower.includes('cc mío 3b') + ) { + return false; + } + return true; + }); + + cleaned = cleanLines.join('\n'); + cleaned = cleaned.replace(GENERIC_FORBIDDEN_WORDS_REGEX, ' '); + + cleaned = cleaned + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return cleaned; +}