feat(sanitizer): excluir estrictamente nombres de grupos y proveedores en titulos y textos de publicaciones

This commit is contained in:
AI Bot 2026-09-03 10:49:37 -05:00
parent 6ca73d559e
commit 3f9de95ce6
20 changed files with 1172 additions and 92 deletions

View File

@ -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}

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -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,

View File

@ -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<ExtractedProductData> {
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 {

View File

@ -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,

View File

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

View File

@ -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<Product[]> {
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<Product[]> {
}
export async function saveProductToAppwrite(product: Product): Promise<Product> {
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<Product>
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<Product>
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 };
}
}

View File

@ -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()

26
scripts/query_vps_db.py Normal file
View File

@ -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()

View File

@ -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)

28
scripts/run_vps.py Normal file
View File

@ -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()

View File

@ -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()

View File

@ -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)

9
scripts/vps_info.py Normal file
View File

@ -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}")

View File

@ -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 <p>, <strong>, <ul>/<li> 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 <p>, <strong>, <ul>/<li> 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.

View File

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

View File

@ -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<ExtractedProductData> {
export async function parseSupplierMessage(
text: string,
extraForbidden: (string | undefined | null)[] = []
): Promise<ExtractedProductData> {
const apiKey = process.env.GEMINI_API_KEY;
const fallbackExtract = (): ExtractedProductData => {
@ -146,7 +173,8 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans';
else if (/coach/i.test(text)) brand = 'Coach';
const cleanName = cleanProductName(text, brand);
const rawClean = cleanProductName(text, brand, extraForbidden);
const cleanName = sanitizePublicationTitle(rawClean, extraForbidden, brand);
const proof = getProductSocialProof({ name: cleanName, brand });
return {
@ -209,7 +237,8 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
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 cleanName = parsed.name || cleanProductName(text, brand);
const rawName = parsed.name || cleanProductName(text, brand, extraForbidden);
const cleanName = sanitizePublicationTitle(rawName, extraForbidden, brand);
const proof = getProductSocialProof({ name: cleanName, brand });
return {

View File

@ -0,0 +1,175 @@
/**
* Módulo de Sanitización para Títulos y Textos de Publicaciones (Zapply)
* 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.
*/
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',
];
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,
];
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;
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function cleanGroupName(raw: string): string {
if (!raw) return '';
return raw
.replace(/[💎🔥👑❤️‍🔥🤯🚨✨🌟⚡👟📦🏢📍🏷️💵💰💸💲]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function buildForbiddenList(extraForbidden: (string | undefined | null)[] = []): string[] {
const terms = new Set<string>();
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;
}