/* code.js — bundle concaténé Généré: 2025-09-04T01:10:08.540Z Source: lib Fichiers: 16 Ordre: topo */ /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/ErrorReporting.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: lib/error-reporting.js - CONVERTI POUR NODE.JS // Description: Système de validation et rapport d'erreur // ======================================== const { google } = require('googleapis'); const nodemailer = require('nodemailer'); const fs = require('fs').promises; const path = require('path'); const pino = require('pino'); const pretty = require('pino-pretty'); const { PassThrough } = require('stream'); const WebSocket = require('ws'); // Configuration const SHEET_ID = process.env.GOOGLE_SHEETS_ID || '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c'; // WebSocket server for real-time logs let wsServer; const wsClients = new Set(); // Enhanced Pino logger configuration with real-time streaming and dated files const now = new Date(); const timestamp = now.toISOString().slice(0, 10) + '_' + now.toLocaleTimeString('fr-FR').replace(/:/g, '-'); const logFile = path.join(__dirname, '..', 'logs', `seo-generator-${timestamp}.log`); const prettyStream = pretty({ colorize: true, translateTime: 'HH:MM:ss.l', ignore: 'pid,hostname', }); const tee = new PassThrough(); tee.pipe(prettyStream).pipe(process.stdout); // File destination with dated filename - FORCE DEBUG LEVEL const fileDest = pino.destination({ dest: logFile, mkdir: true, sync: false, minLength: 0 // Force immediate write even for small logs }); tee.pipe(fileDest); // Custom levels for Pino to include TRACE, PROMPT, and LLM const customLevels = { trace: 5, // Below debug (10) debug: 10, info: 20, prompt: 25, // New level for prompts (between info and warn) llm: 26, // New level for LLM interactions (between prompt and warn) warn: 30, error: 40, fatal: 50 }; // Pino logger instance with enhanced configuration and custom levels const logger = pino( { level: 'debug', // FORCE DEBUG LEVEL for file logging base: undefined, timestamp: pino.stdTimeFunctions.isoTime, customLevels: customLevels, useOnlyCustomLevels: true }, tee ); // Initialize WebSocket server function initWebSocketServer() { if (!wsServer) { wsServer = new WebSocket.Server({ port: process.env.LOG_WS_PORT || 8081 }); wsServer.on('connection', (ws) => { wsClients.add(ws); logger.info('Client connected to log WebSocket'); ws.on('close', () => { wsClients.delete(ws); logger.info('Client disconnected from log WebSocket'); }); ws.on('error', (error) => { logger.error('WebSocket error:', error.message); wsClients.delete(ws); }); }); logger.info(`Log WebSocket server started on port ${process.env.LOG_WS_PORT || 8081}`); } } // Broadcast log to WebSocket clients function broadcastLog(message, level) { const logData = { timestamp: new Date().toISOString(), level: level.toUpperCase(), message: message }; wsClients.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify(logData)); } catch (error) { logger.error('Failed to send log to WebSocket client:', error.message); wsClients.delete(ws); } } }); } // 🔄 NODE.JS : Google Sheets API setup (remplace SpreadsheetApp) let sheets; let auth; async function initGoogleSheets() { if (!sheets) { // Configuration auth Google Sheets API // Pour la démo, on utilise une clé de service (à configurer) auth = new google.auth.GoogleAuth({ keyFile: process.env.GOOGLE_CREDENTIALS_PATH, // Chemin vers fichier JSON credentials scopes: ['https://www.googleapis.com/auth/spreadsheets'] }); sheets = google.sheets({ version: 'v4', auth }); } return sheets; } async function logSh(message, level = 'INFO') { // Initialize WebSocket server if not already done if (!wsServer) { initWebSocketServer(); } // Convert level to lowercase for Pino const pinoLevel = level.toLowerCase(); // Enhanced trace metadata for hierarchical logging const traceData = {}; if (message.includes('▶') || message.includes('✔') || message.includes('✖') || message.includes('•')) { traceData.trace = true; traceData.evt = message.includes('▶') ? 'span.start' : message.includes('✔') ? 'span.end' : message.includes('✖') ? 'span.error' : 'span.event'; } // Log with Pino (handles console output with pretty formatting and file logging) switch (pinoLevel) { case 'error': logger.error(traceData, message); break; case 'warning': case 'warn': logger.warn(traceData, message); break; case 'debug': logger.debug(traceData, message); break; case 'trace': logger.trace(traceData, message); break; case 'prompt': logger.prompt(traceData, message); break; case 'llm': logger.llm(traceData, message); break; default: logger.info(traceData, message); } // Broadcast to WebSocket clients for real-time viewing broadcastLog(message, level); // Force immediate flush to ensure real-time display and prevent log loss logger.flush(); // Log to Google Sheets if enabled (async, non-blocking) if (process.env.ENABLE_SHEETS_LOGGING === 'true') { setImmediate(() => { logToGoogleSheets(message, level).catch(err => { // Silent fail for Google Sheets logging to avoid recursion }); }); } } // Fonction pour déterminer si on doit logger en console function shouldLogToConsole(messageLevel, configLevel) { const levels = { DEBUG: 0, INFO: 1, WARNING: 2, ERROR: 3 }; return levels[messageLevel] >= levels[configLevel]; } // Log to file is now handled by Pino transport // This function is kept for compatibility but does nothing async function logToFile(message, level) { // Pino handles file logging via transport configuration // This function is deprecated and kept for compatibility only } // 🔄 NODE.JS : Log vers Google Sheets (version async) async function logToGoogleSheets(message, level) { try { const sheetsApi = await initGoogleSheets(); const values = [[ new Date().toISOString(), level, message, 'Node.js workflow' ]]; await sheetsApi.spreadsheets.values.append({ spreadsheetId: SHEET_ID, range: 'Logs!A:D', valueInputOption: 'RAW', insertDataOption: 'INSERT_ROWS', resource: { values } }); } catch (error) { logSh('Échec log Google Sheets: ' + error.message, 'WARNING'); // Using logSh instead of console.warn } } // 🔄 NODE.JS : Version simplifiée cleanLogSheet async function cleanLogSheet() { try { logSh('🧹 Nettoyage logs...', 'INFO'); // Using logSh instead of console.log // 1. Nettoyer fichiers logs locaux (garder 7 derniers jours) await cleanLocalLogs(); // 2. Nettoyer Google Sheets si activé if (process.env.ENABLE_SHEETS_LOGGING === 'true') { await cleanGoogleSheetsLogs(); } logSh('✅ Logs nettoyés', 'INFO'); // Using logSh instead of console.log } catch (error) { logSh('Erreur nettoyage logs: ' + error.message, 'ERROR'); // Using logSh instead of console.error } } async function cleanLocalLogs() { try { // Note: With Pino, log files are managed differently // This function is kept for compatibility with Google Sheets logs cleanup // Pino log rotation should be handled by external tools like logrotate // For now, we keep the basic cleanup for any remaining old log files const logsDir = path.join(__dirname, '../logs'); try { const files = await fs.readdir(logsDir); const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - 7); // Garder 7 jours for (const file of files) { if (file.endsWith('.log')) { const filePath = path.join(logsDir, file); const stats = await fs.stat(filePath); if (stats.mtime < cutoffDate) { await fs.unlink(filePath); logSh(`🗑️ Supprimé log ancien: ${file}`, 'INFO'); } } } } catch (error) { // Directory might not exist, that's fine } } catch (error) { // Silent fail } } async function cleanGoogleSheetsLogs() { try { const sheetsApi = await initGoogleSheets(); // Clear + remettre headers await sheetsApi.spreadsheets.values.clear({ spreadsheetId: SHEET_ID, range: 'Logs!A:D' }); await sheetsApi.spreadsheets.values.update({ spreadsheetId: SHEET_ID, range: 'Logs!A1:D1', valueInputOption: 'RAW', resource: { values: [['Timestamp', 'Level', 'Message', 'Source']] } }); } catch (error) { logSh('Échec nettoyage Google Sheets: ' + error.message, 'WARNING'); // Using logSh instead of console.warn } } // ============= VALIDATION PRINCIPALE - IDENTIQUE ============= function validateWorkflowIntegrity(elements, generatedContent, finalXML, csvData) { logSh('🔍 >>> VALIDATION INTÉGRITÉ WORKFLOW <<<', 'INFO'); // Using logSh instead of console.log const errors = []; const warnings = []; const stats = { elementsExtracted: elements.length, contentGenerated: Object.keys(generatedContent).length, tagsReplaced: 0, tagsRemaining: 0 }; // TEST 1: Détection tags dupliqués const duplicateCheck = detectDuplicateTags(elements); if (duplicateCheck.hasDuplicates) { errors.push({ type: 'DUPLICATE_TAGS', severity: 'HIGH', message: `Tags dupliqués détectés: ${duplicateCheck.duplicates.join(', ')}`, impact: 'Certains contenus ne seront pas remplacés dans le XML final', suggestion: 'Vérifier le template XML pour corriger la structure' }); } // TEST 2: Cohérence éléments extraits vs générés const missingGeneration = elements.filter(el => !generatedContent[el.originalTag]); if (missingGeneration.length > 0) { errors.push({ type: 'MISSING_GENERATION', severity: 'HIGH', message: `${missingGeneration.length} éléments extraits mais non générés`, details: missingGeneration.map(el => el.originalTag), impact: 'Contenu incomplet dans le XML final' }); } // TEST 3: Tags non remplacés dans XML final const remainingTags = (finalXML.match(/\|[^|]*\|/g) || []); stats.tagsRemaining = remainingTags.length; if (remainingTags.length > 0) { errors.push({ type: 'UNREPLACED_TAGS', severity: 'HIGH', message: `${remainingTags.length} tags non remplacés dans le XML final`, details: remainingTags.slice(0, 5), impact: 'XML final contient des placeholders non remplacés' }); } // TEST 4: Variables CSV manquantes const missingVars = detectMissingCSVVariables(csvData); if (missingVars.length > 0) { warnings.push({ type: 'MISSING_CSV_VARIABLES', severity: 'MEDIUM', message: `Variables CSV manquantes: ${missingVars.join(', ')}`, impact: 'Système de génération de mots-clés automatique activé' }); } // TEST 5: Qualité génération IA const generationQuality = assessGenerationQuality(generatedContent); if (generationQuality.errorRate > 0.1) { warnings.push({ type: 'GENERATION_QUALITY', severity: 'MEDIUM', message: `${(generationQuality.errorRate * 100).toFixed(1)}% d'erreurs de génération IA`, impact: 'Qualité du contenu potentiellement dégradée' }); } // CALCUL STATS FINALES stats.tagsReplaced = elements.length - remainingTags.length; stats.successRate = stats.elementsExtracted > 0 ? ((stats.tagsReplaced / elements.length) * 100).toFixed(1) : '100'; const report = { timestamp: new Date().toISOString(), csvData: { mc0: csvData.mc0, t0: csvData.t0 }, stats: stats, errors: errors, warnings: warnings, status: errors.length === 0 ? 'SUCCESS' : 'ERROR' }; const logLevel = report.status === 'SUCCESS' ? 'INFO' : 'ERROR'; logSh(`✅ Validation terminée: ${report.status} (${errors.length} erreurs, ${warnings.length} warnings)`, 'INFO'); // Using logSh instead of console.log // ENVOYER RAPPORT SI ERREURS (async en arrière-plan) if (errors.length > 0 || warnings.length > 2) { sendErrorReport(report).catch(err => { logSh('Erreur envoi rapport: ' + err.message, 'ERROR'); // Using logSh instead of console.error }); } return report; } // ============= HELPERS - IDENTIQUES ============= function detectDuplicateTags(elements) { const tagCounts = {}; const duplicates = []; elements.forEach(element => { const tag = element.originalTag; tagCounts[tag] = (tagCounts[tag] || 0) + 1; if (tagCounts[tag] === 2) { duplicates.push(tag); logSh(`❌ DUPLICATE détecté: ${tag}`, 'ERROR'); // Using logSh instead of console.error } }); return { hasDuplicates: duplicates.length > 0, duplicates: duplicates, counts: tagCounts }; } function detectMissingCSVVariables(csvData) { const missing = []; if (!csvData.mcPlus1 || csvData.mcPlus1.split(',').length < 4) { missing.push('MC+1 (insuffisant)'); } if (!csvData.tPlus1 || csvData.tPlus1.split(',').length < 4) { missing.push('T+1 (insuffisant)'); } if (!csvData.lPlus1 || csvData.lPlus1.split(',').length < 4) { missing.push('L+1 (insuffisant)'); } return missing; } function assessGenerationQuality(generatedContent) { let errorCount = 0; let totalCount = Object.keys(generatedContent).length; Object.values(generatedContent).forEach(content => { if (content && ( content.includes('[ERREUR') || content.includes('ERROR') || content.length < 10 )) { errorCount++; } }); return { errorRate: totalCount > 0 ? errorCount / totalCount : 0, totalGenerated: totalCount, errorsFound: errorCount }; } // 🔄 NODE.JS : Email avec nodemailer (remplace MailApp) async function sendErrorReport(report) { try { logSh('📧 Envoi rapport d\'erreur par email...', 'INFO'); // Using logSh instead of console.log // Configuration nodemailer (Gmail par exemple) const transporter = nodemailer.createTransport({ service: 'gmail', auth: { user: process.env.EMAIL_USER, // 'your-email@gmail.com' pass: process.env.EMAIL_APP_PASSWORD // App password Google } }); const subject = `Erreur Workflow SEO Node.js - ${report.status} - ${report.csvData.mc0}`; const htmlBody = createHTMLReport(report); const mailOptions = { from: process.env.EMAIL_USER, to: 'alexistrouve.pro@gmail.com', subject: subject, html: htmlBody, attachments: [{ filename: `error-report-${Date.now()}.json`, content: JSON.stringify(report, null, 2), contentType: 'application/json' }] }; await transporter.sendMail(mailOptions); logSh('✅ Rapport d\'erreur envoyé par email', 'INFO'); // Using logSh instead of console.log } catch (error) { logSh(`❌ Échec envoi email: ${error.message}`, 'ERROR'); // Using logSh instead of console.error } } // ============= HTML REPORT - IDENTIQUE ============= function createHTMLReport(report) { const statusColor = report.status === 'SUCCESS' ? '#28a745' : '#dc3545'; let html = `

Rapport Workflow SEO Automatisé (Node.js)

Résumé Exécutif

Statut: ${report.status}

Article: ${report.csvData.t0}

Mot-clé: ${report.csvData.mc0}

Taux de réussite: ${report.stats.successRate}%

Timestamp: ${report.timestamp}

Plateforme: Node.js Server

`; if (report.errors.length > 0) { html += `

Erreurs Critiques (${report.errors.length})

`; report.errors.forEach((error, i) => { html += `

${i + 1}. ${error.type}

Message: ${error.message}

Impact: ${error.impact}

${error.suggestion ? `

Solution: ${error.suggestion}

` : ''}
`; }); html += `
`; } if (report.warnings.length > 0) { html += `

Avertissements (${report.warnings.length})

`; report.warnings.forEach((warning, i) => { html += `

${i + 1}. ${warning.type}

${warning.message}

`; }); html += `
`; } html += `

Statistiques Détaillées

Informations Système

`; return html; } // 🔄 NODE.JS EXPORTS module.exports = { logSh, cleanLogSheet, validateWorkflowIntegrity, detectDuplicateTags, detectMissingCSVVariables, assessGenerationQuality, sendErrorReport, createHTMLReport }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/BrainConfig.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: BrainConfig.js - Version Node.js // Description: Configuration cerveau + sélection personnalité IA // ======================================== require('dotenv').config(); const axios = require('axios'); const fs = require('fs').promises; const path = require('path'); // Import de la fonction logSh (assumant qu'elle existe dans votre projet Node.js) const { logSh } = require('./ErrorReporting'); // Configuration const CONFIG = { openai: { apiKey: process.env.OPENAI_API_KEY || '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', endpoint: 'https://api.openai.com/v1/chat/completions' }, dataSource: { type: process.env.DATA_SOURCE_TYPE || 'json', // 'json', 'csv', 'database' instructionsPath: './data/instructions.json', personalitiesPath: './data/personalities.json' } }; /** * FONCTION PRINCIPALE - Équivalent getBrainConfig() * @param {number|object} data - Numéro de ligne ou données directes * @returns {object} Configuration avec données CSV + personnalité */ async function getBrainConfig(data) { try { logSh("🧠 Début getBrainConfig Node.js", "INFO"); // 1. RÉCUPÉRER LES DONNÉES CSV let csvData; if (typeof data === 'number') { // Numéro de ligne fourni - lire depuis fichier csvData = await readInstructionsData(data); } else if (typeof data === 'object' && data.rowNumber) { csvData = await readInstructionsData(data.rowNumber); } else { // Données déjà fournies csvData = data; } logSh(`✅ CSV récupéré: ${csvData.mc0}`, "INFO"); // 2. RÉCUPÉRER LES PERSONNALITÉS const personalities = await getPersonalities(); logSh(`✅ ${personalities.length} personnalités chargées`, "INFO"); // 3. SÉLECTIONNER LA MEILLEURE PERSONNALITÉ VIA IA const selectedPersonality = await selectPersonalityWithAI( csvData.mc0, csvData.t0, personalities ); logSh(`✅ Personnalité sélectionnée: ${selectedPersonality.nom}`, "INFO"); return { success: true, data: { ...csvData, personality: selectedPersonality, timestamp: new Date().toISOString() } }; } catch (error) { logSh(`❌ Erreur getBrainConfig: ${error.message}`, "ERROR"); return { success: false, error: error.message }; } } /** * LIRE DONNÉES INSTRUCTIONS depuis Google Sheets DIRECTEMENT * @param {number} rowNumber - Numéro de ligne (2 = première ligne de données) * @returns {object} Données CSV parsées */ async function readInstructionsData(rowNumber = 2) { try { logSh(`📊 Lecture Google Sheet ligne ${rowNumber}...`, 'INFO'); // NOUVEAU : Lecture directe depuis Google Sheets const { google } = require('googleapis'); // Configuration auth Google Sheets - FORCE utilisation fichier JSON pour éviter problème TLS const keyFilePath = path.join(__dirname, '..', 'seo-generator-470715-85d4a971c1af.json'); const auth = new google.auth.GoogleAuth({ keyFile: keyFilePath, scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'] }); logSh('🔑 Utilisation fichier JSON pour contourner problème TLS OAuth', 'INFO'); const sheets = google.sheets({ version: 'v4', auth }); const SHEET_ID = process.env.GOOGLE_SHEETS_ID || '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c'; // Récupérer la ligne spécifique (A à I au minimum) const response = await sheets.spreadsheets.values.get({ spreadsheetId: SHEET_ID, range: `Instructions!A${rowNumber}:I${rowNumber}` // Ligne spécifique A-I }); if (!response.data.values || response.data.values.length === 0) { throw new Error(`Ligne ${rowNumber} non trouvée dans Google Sheet`); } const row = response.data.values[0]; logSh(`✅ Ligne ${rowNumber} récupérée: ${row.length} colonnes`, 'INFO'); const xmlTemplateValue = row[8] || ''; let xmlTemplate = xmlTemplateValue; let xmlFileName = null; // Si c'est un nom de fichier, garder le nom ET utiliser un template par défaut if (xmlTemplateValue && xmlTemplateValue.endsWith('.xml') && xmlTemplateValue.length < 100) { logSh(`🔧 XML filename detected (${xmlTemplateValue}), keeping filename for Digital Ocean`, 'INFO'); xmlFileName = xmlTemplateValue; // Garder le nom du fichier pour Digital Ocean xmlTemplate = createDefaultXMLTemplate(); // Template par défaut pour le processing } return { rowNumber: rowNumber, slug: row[0] || '', // Colonne A t0: row[1] || '', // Colonne B mc0: row[2] || '', // Colonne C tMinus1: row[3] || '', // Colonne D lMinus1: row[4] || '', // Colonne E mcPlus1: row[5] || '', // Colonne F tPlus1: row[6] || '', // Colonne G lPlus1: row[7] || '', // Colonne H xmlTemplate: xmlTemplate, // XML template pour processing xmlFileName: xmlFileName // Nom fichier pour Digital Ocean (si applicable) }; } catch (error) { logSh(`❌ Erreur lecture Google Sheet: ${error.message}`, "ERROR"); throw error; } } /** * RÉCUPÉRER PERSONNALITÉS depuis l'onglet "Personnalites" du Google Sheet * @returns {Array} Liste des personnalités disponibles */ async function getPersonalities() { try { logSh('📊 Lecture personnalités depuis Google Sheet (onglet Personnalites)...', 'INFO'); // Configuration auth Google Sheets - FORCE utilisation fichier JSON pour éviter problème TLS const { google } = require('googleapis'); const keyFilePath = path.join(__dirname, '..', 'seo-generator-470715-85d4a971c1af.json'); const auth = new google.auth.GoogleAuth({ keyFile: keyFilePath, scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'] }); logSh('🔑 Utilisation fichier JSON pour contourner problème TLS OAuth (personnalités)', 'INFO'); const sheets = google.sheets({ version: 'v4', auth }); const SHEET_ID = process.env.GOOGLE_SHEETS_ID || '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c'; // Récupérer toutes les personnalités (après la ligne d'en-tête) const response = await sheets.spreadsheets.values.get({ spreadsheetId: SHEET_ID, range: 'Personnalites!A2:O' // Colonnes A à O pour inclure les nouvelles colonnes IA }); if (!response.data.values || response.data.values.length === 0) { throw new Error('Aucune personnalité trouvée dans l\'onglet Personnalites'); } const personalities = []; // Traiter chaque ligne de personnalité response.data.values.forEach((row, index) => { if (row[0] && row[0].toString().trim() !== '') { // Si nom existe (colonne A) const personality = { nom: row[0]?.toString().trim() || '', description: row[1]?.toString().trim() || 'Expert généraliste', style: row[2]?.toString().trim() || 'professionnel', // Configuration avancée depuis colonnes Google Sheet motsClesSecteurs: parseCSVField(row[3]), vocabulairePref: parseCSVField(row[4]), connecteursPref: parseCSVField(row[5]), erreursTypiques: parseCSVField(row[6]), longueurPhrases: row[7]?.toString().trim() || 'moyennes', niveauTechnique: row[8]?.toString().trim() || 'moyen', ctaStyle: parseCSVField(row[9]), defautsSimules: parseCSVField(row[10]), // NOUVEAU: Configuration IA par étape depuis Google Sheets (colonnes L-O) aiEtape1Base: row[11]?.toString().trim().toLowerCase() || '', aiEtape2Technique: row[12]?.toString().trim().toLowerCase() || '', aiEtape3Transitions: row[13]?.toString().trim().toLowerCase() || '', aiEtape4Style: row[14]?.toString().trim().toLowerCase() || '', // Backward compatibility motsCles: parseCSVField(row[3] || '') // Utilise motsClesSecteurs }; personalities.push(personality); logSh(`✓ Personnalité chargée: ${personality.nom} (${personality.style})`, 'DEBUG'); } }); logSh(`📊 ${personalities.length} personnalités chargées depuis Google Sheet`, "INFO"); return personalities; } catch (error) { logSh(`❌ ÉCHEC: Impossible de récupérer les personnalités Google Sheets - ${error.message}`, "ERROR"); throw new Error(`FATAL: Personnalités Google Sheets inaccessibles - arrêt du workflow: ${error.message}`); } } /** * PARSER CHAMP CSV - Helper function * @param {string} field - Champ à parser * @returns {Array} Liste des éléments parsés */ function parseCSVField(field) { if (!field || field.toString().trim() === '') return []; return field.toString() .split(',') .map(item => item.trim()) .filter(item => item.length > 0); } /** * Sélectionner un sous-ensemble aléatoire de personnalités * @param {Array} allPersonalities - Liste complète des personnalités * @param {number} percentage - Pourcentage à garder (0.6 = 60%) * @returns {Array} Sous-ensemble aléatoire */ function selectRandomPersonalities(allPersonalities, percentage = 0.6) { const count = Math.ceil(allPersonalities.length * percentage); // Mélanger avec Fisher-Yates shuffle (meilleur que sort()) const shuffled = [...allPersonalities]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled.slice(0, count); } /** * NOUVELLE FONCTION: Sélection de 4 personnalités complémentaires pour le pipeline multi-AI * @param {string} mc0 - Mot-clé principal * @param {string} t0 - Titre principal * @param {Array} personalities - Liste des personnalités * @returns {Array} 4 personnalités sélectionnées pour chaque étape */ async function selectMultiplePersonalitiesWithAI(mc0, t0, personalities) { try { logSh(`🎭 Sélection MULTI-personnalités IA pour: ${mc0}`, "INFO"); // Sélection aléatoire de 80% des personnalités (plus large pour 4 choix) const randomPersonalities = selectRandomPersonalities(personalities, 0.8); const totalCount = personalities.length; const selectedCount = randomPersonalities.length; logSh(`🎲 Pool aléatoire: ${selectedCount}/${totalCount} personnalités disponibles`, "DEBUG"); logSh(`📋 Personnalités dans le pool: ${randomPersonalities.map(p => p.nom).join(', ')}`, "DEBUG"); const prompt = `Choisis 4 personnalités COMPLÉMENTAIRES pour générer du contenu sur "${mc0}": OBJECTIF: Créer une équipe de 4 rédacteurs avec styles différents mais cohérents PERSONNALITÉS DISPONIBLES: ${randomPersonalities.map(p => `- ${p.nom}: ${p.description} (Style: ${p.style})`).join('\n')} RÔLES À ATTRIBUER: 1. GÉNÉRATEUR BASE: Personnalité technique/experte pour la génération initiale 2. ENHANCER TECHNIQUE: Personnalité commerciale/précise pour améliorer les termes techniques 3. FLUIDITÉ: Personnalité créative/littéraire pour améliorer les transitions 4. STYLE FINAL: Personnalité terrain/accessible pour le style final CRITÈRES: - 4 personnalités aux styles DIFFÉRENTS mais complémentaires - Adapté au secteur: ${mc0} - Variabilité maximale pour anti-détection - Éviter les doublons de style FORMAT DE RÉPONSE (EXACTEMENT 4 noms séparés par des virgules): Nom1, Nom2, Nom3, Nom4`; const requestData = { model: "gpt-4o-mini", messages: [{"role": "user", "content": prompt}], max_tokens: 100, temperature: 1.0 }; const response = await axios.post(CONFIG.openai.endpoint, requestData, { headers: { 'Authorization': `Bearer ${CONFIG.openai.apiKey}`, 'Content-Type': 'application/json' }, timeout: 300000 }); const selectedNames = response.data.choices[0].message.content.trim() .split(',') .map(name => name.trim()); logSh(`🔍 Noms retournés par IA: ${selectedNames.join(', ')}`, "DEBUG"); // Mapper aux vraies personnalités const selectedPersonalities = []; selectedNames.forEach(name => { const personality = randomPersonalities.find(p => p.nom === name); if (personality) { selectedPersonalities.push(personality); } }); // Compléter si pas assez de personnalités trouvées (sécurité) while (selectedPersonalities.length < 4 && randomPersonalities.length > selectedPersonalities.length) { const remaining = randomPersonalities.filter(p => !selectedPersonalities.some(selected => selected.nom === p.nom) ); if (remaining.length > 0) { const randomIndex = Math.floor(Math.random() * remaining.length); selectedPersonalities.push(remaining[randomIndex]); } else { break; } } // Garantir exactement 4 personnalités const final4Personalities = selectedPersonalities.slice(0, 4); logSh(`✅ Équipe de 4 personnalités sélectionnée:`, "INFO"); final4Personalities.forEach((p, index) => { const roles = ['BASE', 'TECHNIQUE', 'FLUIDITÉ', 'STYLE']; logSh(` ${index + 1}. ${roles[index]}: ${p.nom} (${p.style})`, "INFO"); }); return final4Personalities; } catch (error) { logSh(`❌ FATAL: Sélection multi-personnalités échouée: ${error.message}`, "ERROR"); throw new Error(`FATAL: Sélection multi-personnalités IA impossible - arrêt du workflow: ${error.message}`); } } /** * FONCTION LEGACY: Sélection personnalité unique (maintenue pour compatibilité) * @param {string} mc0 - Mot-clé principal * @param {string} t0 - Titre principal * @param {Array} personalities - Liste des personnalités * @returns {object} Personnalité sélectionnée */ async function selectPersonalityWithAI(mc0, t0, personalities) { try { logSh(`🤖 Sélection personnalité IA UNIQUE pour: ${mc0}`, "DEBUG"); // Appeler la fonction multi et prendre seulement la première const multiPersonalities = await selectMultiplePersonalitiesWithAI(mc0, t0, personalities); const selectedPersonality = multiPersonalities[0]; logSh(`✅ Personnalité IA sélectionnée (mode legacy): ${selectedPersonality.nom}`, "INFO"); return selectedPersonality; } catch (error) { logSh(`❌ FATAL: Sélection personnalité par IA échouée: ${error.message}`, "ERROR"); throw new Error(`FATAL: Sélection personnalité IA inaccessible - arrêt du workflow: ${error.message}`); } } /** * CRÉER TEMPLATE XML PAR DÉFAUT quand colonne I contient un nom de fichier * Utilise les données CSV disponibles pour créer un template robuste */ function createDefaultXMLTemplate() { return `

|Titre_Principal{{T0}}{Rédige un titre H1 accrocheur de maximum 10 mots pour {{MC0}}. Style {{personality.style}}}|

|Introduction{{MC0}}{Rédige une introduction engageante de 2-3 phrases sur {{MC0}}. Ton {{personality.style}}, utilise {{personality.vocabulairePref}}}|

|Titre_H2_1{{MC+1_1}}{Crée un titre H2 informatif sur {{MC+1_1}}. Style {{personality.style}}}|

|Paragraphe_1{{MC+1_1}}{Rédige un paragraphe détaillé de 4-5 phrases sur {{MC+1_1}}. Explique les avantages et caractéristiques. Ton {{personality.style}}}|

|Titre_H2_2{{MC+1_2}}{Titre H2 pour {{MC+1_2}}. Mets en valeur les points forts. Ton {{personality.style}}}|

|Paragraphe_2{{MC+1_2}}{Paragraphe de 4-5 phrases sur {{MC+1_2}}. Détaille pourquoi c'est important pour {{MC0}}. Ton {{personality.style}}}|

|Titre_H2_3{{MC+1_3}}{Titre H2 sur les bénéfices de {{MC+1_3}}. Accrocheur et informatif}|

|Paragraphe_3{{MC+1_3}}{Explique en 4-5 phrases les avantages de {{MC+1_3}} pour {{MC0}}. Ton {{personality.style}}}|

`; } /** * CRÉER FICHIERS DE DONNÉES D'EXEMPLE * Fonction utilitaire pour initialiser les fichiers JSON */ async function createSampleDataFiles() { try { // Créer répertoire data s'il n'existe pas await fs.mkdir('./data', { recursive: true }); // Exemple instructions.json const sampleInstructions = [ { slug: "plaque-test", t0: "Plaque test signalétique", mc0: "plaque signalétique", "t-1": "Signalétique", "l-1": "/signaletique/", "mc+1": "plaque dibond, plaque aluminium, plaque PVC", "t+1": "Plaque dibond, Plaque alu, Plaque PVC", "l+1": "/plaque-dibond/, /plaque-aluminium/, /plaque-pvc/", xmlFileName: "template-plaque.xml" } ]; // Exemple personalities.json const samplePersonalities = [ { nom: "Marc", description: "Expert technique en signalétique", style: "professionnel et précis", motsClesSecteurs: "technique,dibond,aluminium,impression", vocabulairePref: "précision,qualité,expertise,performance", connecteursPref: "par ailleurs,en effet,notamment,cependant", erreursTypiques: "accord_proximite,repetition_legere", longueurPhrases: "moyennes", niveauTechnique: "élevé", ctaStyle: "découvrir,choisir,commander", defautsSimules: "fatigue_cognitive,hesitation_technique" }, { nom: "Sophie", description: "Passionnée de décoration et design", style: "familier et chaleureux", motsClesSecteurs: "décoration,design,esthétique,tendances", vocabulairePref: "joli,magnifique,tendance,style", connecteursPref: "du coup,en fait,sinon,au fait", erreursTypiques: "familiarite_excessive,expression_populaire", longueurPhrases: "courtes", niveauTechnique: "moyen", ctaStyle: "craquer,adopter,foncer", defautsSimules: "enthousiasme_variable,anecdote_personnelle" } ]; // Écrire les fichiers await fs.writeFile('./data/instructions.json', JSON.stringify(sampleInstructions, null, 2)); await fs.writeFile('./data/personalities.json', JSON.stringify(samplePersonalities, null, 2)); logSh('✅ Fichiers de données d\'exemple créés dans ./data/', "INFO"); } catch (error) { logSh(`❌ Erreur création fichiers exemple: ${error.message}`, "ERROR"); } } // ============= EXPORTS NODE.JS ============= module.exports = { getBrainConfig, getPersonalities, selectPersonalityWithAI, selectMultiplePersonalitiesWithAI, // NOUVEAU: Export de la fonction multi-personnalités selectRandomPersonalities, parseCSVField, readInstructionsData, createSampleDataFiles, createDefaultXMLTemplate, CONFIG }; // ============= TEST RAPIDE SI LANCÉ DIRECTEMENT ============= if (require.main === module) { (async () => { try { logSh('🧪 Test BrainConfig Node.js...', "INFO"); // Créer fichiers exemple si nécessaire try { await fs.access('./data/instructions.json'); } catch { await createSampleDataFiles(); } // Test de la fonction principale const result = await getBrainConfig(2); if (result.success) { logSh(`✅ Test réussi: ${result.data.personality.nom} pour ${result.data.mc0}`, "INFO"); } else { logSh(`❌ Test échoué: ${result.error}`, "ERROR"); } } catch (error) { logSh(`❌ Erreur test: ${error.message}`, "ERROR"); } })(); } /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/LLMManager.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: LLMManager.js // Description: Hub central pour tous les appels LLM (Version Node.js) // Support: Claude, OpenAI, Gemini, Deepseek, Moonshot, Mistral // ======================================== const fetch = globalThis.fetch.bind(globalThis); const { logSh } = require('./ErrorReporting'); // ============= CONFIGURATION CENTRALISÉE ============= const LLM_CONFIG = { openai: { apiKey: process.env.OPENAI_API_KEY || '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', endpoint: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o-mini', headers: { 'Authorization': 'Bearer {API_KEY}', 'Content-Type': 'application/json' }, temperature: 0.7, timeout: 300000, // 5 minutes retries: 3 }, claude: { apiKey: process.env.CLAUDE_API_KEY || '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', endpoint: 'https://api.anthropic.com/v1/messages', model: 'claude-sonnet-4-20250514', headers: { 'x-api-key': '{API_KEY}', 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' }, temperature: 0.7, timeout: 300000, // 5 minutes retries: 6 }, gemini: { apiKey: process.env.GEMINI_API_KEY || 'AIzaSyAMzmIGbW5nJlBG5Qyr35sdjb3U2bIBtoE', endpoint: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent', model: 'gemini-2.5-flash', headers: { 'Content-Type': 'application/json' }, temperature: 0.7, maxTokens: 6000, timeout: 300000, // 5 minutes retries: 3 }, deepseek: { apiKey: process.env.DEEPSEEK_API_KEY || 'sk-6e02bc9513884bb8b92b9920524e17b5', endpoint: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-chat', headers: { 'Authorization': 'Bearer {API_KEY}', 'Content-Type': 'application/json' }, temperature: 0.7, timeout: 300000, // 5 minutes retries: 3 }, moonshot: { apiKey: process.env.MOONSHOT_API_KEY || 'sk-zU9gyNkux2zcsj61cdKfztuP1Jozr6lFJ9viUJRPD8p8owhL', endpoint: 'https://api.moonshot.ai/v1/chat/completions', model: 'moonshot-v1-32k', headers: { 'Authorization': 'Bearer {API_KEY}', 'Content-Type': 'application/json' }, temperature: 0.7, timeout: 300000, // 5 minutes retries: 3 }, mistral: { apiKey: process.env.MISTRAL_API_KEY || 'wESikMCIuixajSH8WHCiOV2z5sevgmVF', endpoint: 'https://api.mistral.ai/v1/chat/completions', model: 'mistral-small-latest', headers: { 'Authorization': 'Bearer {API_KEY}', 'Content-Type': 'application/json' }, max_tokens: 5000, temperature: 0.7, timeout: 300000, // 5 minutes retries: 3 } }; // ============= HELPER FUNCTIONS ============= const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); // ============= INTERFACE UNIVERSELLE ============= /** * Fonction principale pour appeler n'importe quel LLM * @param {string} llmProvider - claude|openai|gemini|deepseek|moonshot|mistral * @param {string} prompt - Le prompt à envoyer * @param {object} options - Options personnalisées (température, tokens, etc.) * @param {object} personality - Personnalité pour contexte système * @returns {Promise} - Réponse générée */ async function callLLM(llmProvider, prompt, options = {}, personality = null) { const startTime = Date.now(); try { // Vérifier si le provider existe if (!LLM_CONFIG[llmProvider]) { throw new Error(`Provider LLM inconnu: ${llmProvider}`); } // Vérifier si l'API key est configurée const config = LLM_CONFIG[llmProvider]; if (!config.apiKey || config.apiKey.startsWith('VOTRE_CLE_')) { throw new Error(`Clé API manquante pour ${llmProvider}`); } logSh(`🤖 Appel LLM: ${llmProvider.toUpperCase()} (${config.model}) | Personnalité: ${personality?.nom || 'aucune'}`, 'DEBUG'); // 📢 AFFICHAGE PROMPT COMPLET POUR DEBUG AVEC INFO IA logSh(`\n🔍 ===== PROMPT ENVOYÉ À ${llmProvider.toUpperCase()} (${config.model}) | PERSONNALITÉ: ${personality?.nom || 'AUCUNE'} =====`, 'PROMPT'); logSh(prompt, 'PROMPT'); // 📤 LOG LLM REQUEST COMPLET logSh(`📤 LLM REQUEST [${llmProvider.toUpperCase()}] (${config.model}) | Personnalité: ${personality?.nom || 'AUCUNE'}`, 'LLM'); logSh(prompt, 'LLM'); // Préparer la requête selon le provider const requestData = buildRequestData(llmProvider, prompt, options, personality); // Effectuer l'appel avec retry logic const response = await callWithRetry(llmProvider, requestData, config); // Parser la réponse selon le format du provider const content = parseResponse(llmProvider, response); // 📥 LOG LLM RESPONSE COMPLET logSh(`📥 LLM RESPONSE [${llmProvider.toUpperCase()}] (${config.model}) | Durée: ${Date.now() - startTime}ms`, 'LLM'); logSh(content, 'LLM'); const duration = Date.now() - startTime; logSh(`✅ ${llmProvider.toUpperCase()} (${personality?.nom || 'sans personnalité'}) réponse en ${duration}ms`, 'INFO'); // Enregistrer les stats d'usage await recordUsageStats(llmProvider, prompt.length, content.length, duration); return content; } catch (error) { const duration = Date.now() - startTime; logSh(`❌ Erreur ${llmProvider.toUpperCase()} (${personality?.nom || 'sans personnalité'}): ${error.toString()}`, 'ERROR'); // Enregistrer l'échec await recordUsageStats(llmProvider, prompt.length, 0, duration, error.toString()); throw error; } } // ============= CONSTRUCTION DES REQUÊTES ============= function buildRequestData(provider, prompt, options, personality) { const config = LLM_CONFIG[provider]; const temperature = options.temperature || config.temperature; const maxTokens = options.maxTokens || config.maxTokens; // Construire le système prompt si personnalité fournie const systemPrompt = personality ? `Tu es ${personality.nom}. ${personality.description}. Style: ${personality.style}` : 'Tu es un assistant expert.'; switch (provider) { case 'openai': case 'deepseek': case 'moonshot': case 'mistral': return { model: config.model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: prompt } ], max_tokens: maxTokens, temperature: temperature, stream: false }; case 'claude': return { model: config.model, max_tokens: maxTokens, temperature: temperature, system: systemPrompt, messages: [ { role: 'user', content: prompt } ] }; case 'gemini': return { contents: [{ parts: [{ text: `${systemPrompt}\n\n${prompt}` }] }], generationConfig: { temperature: temperature, maxOutputTokens: maxTokens } }; default: throw new Error(`Format de requête non supporté pour ${provider}`); } } // ============= APPELS AVEC RETRY ============= async function callWithRetry(provider, requestData, config) { let lastError; for (let attempt = 1; attempt <= config.retries; attempt++) { try { logSh(`🔄 Tentative ${attempt}/${config.retries} pour ${provider.toUpperCase()}`, 'DEBUG'); // Préparer les headers avec la clé API const headers = {}; Object.keys(config.headers).forEach(key => { headers[key] = config.headers[key].replace('{API_KEY}', config.apiKey); }); // URL avec clé API pour Gemini (cas spécial) let url = config.endpoint; if (provider === 'gemini') { url += `?key=${config.apiKey}`; } const options = { method: 'POST', headers: headers, body: JSON.stringify(requestData), timeout: config.timeout }; const response = await fetch(url, options); const responseText = await response.text(); if (response.ok) { return JSON.parse(responseText); } else if (response.status === 429) { // Rate limiting - attendre plus longtemps const waitTime = Math.pow(2, attempt) * 1000; // Exponential backoff logSh(`⏳ Rate limit ${provider.toUpperCase()}, attente ${waitTime}ms`, 'WARNING'); await sleep(waitTime); continue; } else { throw new Error(`HTTP ${response.status}: ${responseText}`); } } catch (error) { lastError = error; if (attempt < config.retries) { const waitTime = 1000 * attempt; logSh(`⚠ Erreur tentative ${attempt}: ${error.toString()}, retry dans ${waitTime}ms`, 'WARNING'); await sleep(waitTime); } } } throw new Error(`Échec après ${config.retries} tentatives: ${lastError.toString()}`); } // ============= PARSING DES RÉPONSES ============= function parseResponse(provider, responseData) { try { switch (provider) { case 'openai': case 'deepseek': case 'moonshot': case 'mistral': return responseData.choices[0].message.content.trim(); case 'claude': return responseData.content[0].text.trim(); case 'gemini': const candidate = responseData.candidates[0]; // Vérifications multiples pour Gemini 2.5 if (candidate && candidate.content && candidate.content.parts && candidate.content.parts[0] && candidate.content.parts[0].text) { return candidate.content.parts[0].text.trim(); } else if (candidate && candidate.text) { return candidate.text.trim(); } else if (candidate && candidate.content && candidate.content.text) { return candidate.content.text.trim(); } else { // Debug : logger la structure complète logSh('Gemini structure complète: ' + JSON.stringify(responseData), 'DEBUG'); return '[Gemini: pas de texte généré - problème modèle]'; } default: throw new Error(`Parser non supporté pour ${provider}`); } } catch (error) { logSh(`❌ Erreur parsing ${provider}: ${error.toString()}`, 'ERROR'); logSh(`Response brute: ${JSON.stringify(responseData)}`, 'DEBUG'); throw new Error(`Impossible de parser la réponse ${provider}: ${error.toString()}`); } } // ============= GESTION DES STATISTIQUES ============= async function recordUsageStats(provider, promptTokens, responseTokens, duration, error = null) { try { // TODO: Adapter selon votre système de stockage Node.js // Peut être une base de données, un fichier, MongoDB, etc. const statsData = { timestamp: new Date(), provider: provider, model: LLM_CONFIG[provider].model, promptTokens: promptTokens, responseTokens: responseTokens, duration: duration, error: error || '' }; // Exemple: log vers console ou fichier logSh(`📊 Stats: ${JSON.stringify(statsData)}`, 'DEBUG'); // TODO: Implémenter sauvegarde réelle (DB, fichier, etc.) } catch (statsError) { // Ne pas faire planter le workflow si les stats échouent logSh(`⚠ Erreur enregistrement stats: ${statsError.toString()}`, 'WARNING'); } } // ============= FONCTIONS UTILITAIRES ============= /** * Tester la connectivité de tous les LLMs */ async function testAllLLMs() { const testPrompt = "Dis bonjour en 5 mots maximum."; const results = {}; const allProviders = Object.keys(LLM_CONFIG); for (const provider of allProviders) { try { logSh(`🧪 Test ${provider}...`, 'INFO'); const response = await callLLM(provider, testPrompt); results[provider] = { status: 'SUCCESS', response: response, model: LLM_CONFIG[provider].model }; } catch (error) { results[provider] = { status: 'ERROR', error: error.toString(), model: LLM_CONFIG[provider].model }; } // Petit délai entre tests await sleep(500); } logSh(`📊 Tests terminés: ${JSON.stringify(results, null, 2)}`, 'INFO'); return results; } /** * Obtenir les providers disponibles (avec clés API valides) */ function getAvailableProviders() { const available = []; Object.keys(LLM_CONFIG).forEach(provider => { const config = LLM_CONFIG[provider]; if (config.apiKey && !config.apiKey.startsWith('VOTRE_CLE_')) { available.push(provider); } }); return available; } /** * Obtenir des statistiques d'usage par provider */ async function getUsageStats() { try { // TODO: Adapter selon votre système de stockage // Pour l'instant retourne un message par défaut return { message: 'Statistiques non implémentées en Node.js' }; } catch (error) { return { error: error.toString() }; } } // ============= MIGRATION DE L'ANCIEN CODE ============= /** * Fonction de compatibilité pour remplacer votre ancien callOpenAI() * Maintient la même signature pour ne pas casser votre code existant */ async function callOpenAI(prompt, personality) { return await callLLM('openai', prompt, {}, personality); } // ============= EXPORTS POUR TESTS ============= /** * Fonction de test rapide */ async function testLLMManager() { logSh('🚀 Test du LLM Manager Node.js...', 'INFO'); // Test des providers disponibles const available = getAvailableProviders(); logSh('Providers disponibles: ' + available.join(', ') + ' (' + available.length + '/6)', 'INFO'); // Test d'appel simple sur chaque provider disponible for (const provider of available) { try { logSh(`🧪 Test ${provider}...`, 'DEBUG'); const startTime = Date.now(); const response = await callLLM(provider, 'Dis juste "Test OK"'); const duration = Date.now() - startTime; logSh(`✅ Test ${provider} réussi: "${response}" (${duration}ms)`, 'INFO'); } catch (error) { logSh(`❌ Test ${provider} échoué: ${error.toString()}`, 'ERROR'); } // Petit délai pour éviter rate limits await sleep(500); } // Test spécifique OpenAI (compatibilité avec ancien code) try { logSh('🎯 Test spécifique OpenAI (compatibilité)...', 'DEBUG'); const response = await callLLM('openai', 'Dis juste "Test OK"'); logSh('✅ Test OpenAI compatibilité: ' + response, 'INFO'); } catch (error) { logSh('❌ Test OpenAI compatibilité échoué: ' + error.toString(), 'ERROR'); } // Afficher les stats d'usage try { logSh('📊 Récupération statistiques d\'usage...', 'DEBUG'); const stats = await getUsageStats(); if (stats.error) { logSh('⚠ Erreur récupération stats: ' + stats.error, 'WARNING'); } else if (stats.message) { logSh('📊 Stats: ' + stats.message, 'INFO'); } else { // Formatter les stats pour les logs Object.keys(stats).forEach(provider => { const s = stats[provider]; logSh(`📈 ${provider}: ${s.calls} appels, ${s.successRate}% succès, ${s.avgDuration}ms moyen`, 'INFO'); }); } } catch (error) { logSh('❌ Erreur lors de la récupération des stats: ' + error.toString(), 'ERROR'); } // Résumé final const workingCount = available.length; const totalProviders = Object.keys(LLM_CONFIG).length; if (workingCount === totalProviders) { logSh(`✅ Test LLM Manager COMPLET: ${workingCount}/${totalProviders} providers opérationnels`, 'INFO'); } else if (workingCount >= 2) { logSh(`✅ Test LLM Manager PARTIEL: ${workingCount}/${totalProviders} providers opérationnels (suffisant pour DNA Mixing)`, 'INFO'); } else { logSh(`❌ Test LLM Manager INSUFFISANT: ${workingCount}/${totalProviders} providers opérationnels (minimum 2 requis)`, 'ERROR'); } logSh('🏁 Test LLM Manager terminé', 'INFO'); } /** * Version complète avec test de tous les providers (même non configurés) */ async function testLLMManagerComplete() { logSh('🚀 Test COMPLET du LLM Manager (tous providers)...', 'INFO'); const allProviders = Object.keys(LLM_CONFIG); logSh(`Providers configurés: ${allProviders.join(', ')}`, 'INFO'); const results = { configured: 0, working: 0, failed: 0 }; for (const provider of allProviders) { const config = LLM_CONFIG[provider]; // Vérifier si configuré if (!config.apiKey || config.apiKey.startsWith('VOTRE_CLE_')) { logSh(`⚙️ ${provider}: NON CONFIGURÉ (clé API manquante)`, 'WARNING'); continue; } results.configured++; try { logSh(`🧪 Test ${provider} (${config.model})...`, 'DEBUG'); const startTime = Date.now(); const response = await callLLM(provider, 'Réponds "OK" seulement.', { maxTokens: 100 }); const duration = Date.now() - startTime; results.working++; logSh(`✅ ${provider}: "${response.trim()}" (${duration}ms)`, 'INFO'); } catch (error) { results.failed++; logSh(`❌ ${provider}: ${error.toString()}`, 'ERROR'); } // Délai entre tests await sleep(700); } // Résumé final complet logSh(`📊 RÉSUMÉ FINAL:`, 'INFO'); logSh(` • Providers total: ${allProviders.length}`, 'INFO'); logSh(` • Configurés: ${results.configured}`, 'INFO'); logSh(` • Fonctionnels: ${results.working}`, 'INFO'); logSh(` • En échec: ${results.failed}`, 'INFO'); const status = results.working >= 4 ? 'EXCELLENT' : results.working >= 2 ? 'BON' : 'INSUFFISANT'; logSh(`🏆 STATUS: ${status} (${results.working} LLMs opérationnels)`, status === 'INSUFFISANT' ? 'ERROR' : 'INFO'); logSh('🏁 Test LLM Manager COMPLET terminé', 'INFO'); return { total: allProviders.length, configured: results.configured, working: results.working, failed: results.failed, status: status }; } // ============= EXPORTS MODULE ============= module.exports = { callLLM, callOpenAI, testAllLLMs, getAvailableProviders, getUsageStats, testLLMManager, testLLMManagerComplete, LLM_CONFIG }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/ElementExtraction.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: lib/element-extraction.js - CONVERTI POUR NODE.JS // Description: Extraction et parsing des éléments XML // ======================================== // 🔄 NODE.JS IMPORTS const { logSh } = require('./ErrorReporting'); // ============= EXTRACTION PRINCIPALE ============= async function extractElements(xmlTemplate, csvData) { try { await logSh('Extraction éléments avec séparation tag/contenu...', 'DEBUG'); const regex = /\|([^|]+)\|/g; const elements = []; let match; while ((match = regex.exec(xmlTemplate)) !== null) { const fullMatch = match[1]; // Ex: "Titre_H1_1{{T0}}" ou "Titre_H3_3{{MC+1_3}}" // Séparer nom du tag et variables const nameMatch = fullMatch.match(/^([^{]+)/); const variablesMatch = fullMatch.match(/\{\{([^}]+)\}\}/g); // FIX REGEX INSTRUCTIONS - Enlever d'abord les {{variables}} puis chercher {instructions} const withoutVariables = fullMatch.replace(/\{\{[^}]+\}\}/g, ''); const instructionsMatch = withoutVariables.match(/\{([^}]+)\}/); const tagName = nameMatch ? nameMatch[1].trim() : fullMatch.split('{')[0]; // TAG PUR (sans variables) const pureTag = `|${tagName}|`; // RÉSOUDRE le contenu des variables const resolvedContent = resolveVariablesContent(variablesMatch, csvData); elements.push({ originalTag: pureTag, // ← TAG PUR : |Titre_H3_3| name: tagName, // ← Titre_H3_3 variables: variablesMatch || [], // ← [{{MC+1_3}}] resolvedContent: resolvedContent, // ← "Plaque de rue en aluminium" instructions: instructionsMatch ? instructionsMatch[1] : null, type: getElementType(tagName), originalFullMatch: fullMatch // ← Backup si besoin }); await logSh(`Tag séparé: ${pureTag} → "${resolvedContent}"`, 'DEBUG'); } await logSh(`${elements.length} éléments extraits avec séparation`, 'INFO'); return elements; } catch (error) { await logSh(`Erreur extractElements: ${error}`, 'ERROR'); return []; } } // ============= RÉSOLUTION VARIABLES - IDENTIQUE ============= function resolveVariablesContent(variablesMatch, csvData) { if (!variablesMatch || variablesMatch.length === 0) { return ""; // Pas de variables à résoudre } let resolvedContent = ""; variablesMatch.forEach(variable => { const cleanVar = variable.replace(/[{}]/g, ''); // Enlever {{ }} switch (cleanVar) { case 'T0': resolvedContent += csvData.t0; break; case 'MC0': resolvedContent += csvData.mc0; break; case 'T-1': resolvedContent += csvData.tMinus1; break; case 'L-1': resolvedContent += csvData.lMinus1; break; default: // Gérer MC+1_1, MC+1_2, etc. if (cleanVar.startsWith('MC+1_')) { const index = parseInt(cleanVar.split('_')[1]) - 1; const mcPlus1 = csvData.mcPlus1.split(',').map(s => s.trim()); resolvedContent += mcPlus1[index] || `[${cleanVar} non défini]`; } else if (cleanVar.startsWith('T+1_')) { const index = parseInt(cleanVar.split('_')[1]) - 1; const tPlus1 = csvData.tPlus1.split(',').map(s => s.trim()); resolvedContent += tPlus1[index] || `[${cleanVar} non défini]`; } else if (cleanVar.startsWith('L+1_')) { const index = parseInt(cleanVar.split('_')[1]) - 1; const lPlus1 = csvData.lPlus1.split(',').map(s => s.trim()); resolvedContent += lPlus1[index] || `[${cleanVar} non défini]`; } else { resolvedContent += `[${cleanVar} non résolu]`; } break; } }); return resolvedContent; } // ============= CLASSIFICATION ÉLÉMENTS - IDENTIQUE ============= function getElementType(name) { if (name.includes('Titre_H1')) return 'titre_h1'; if (name.includes('Titre_H2')) return 'titre_h2'; if (name.includes('Titre_H3')) return 'titre_h3'; if (name.includes('Intro_')) return 'intro'; if (name.includes('Txt_')) return 'texte'; if (name.includes('Faq_q')) return 'faq_question'; if (name.includes('Faq_a')) return 'faq_reponse'; if (name.includes('Faq_H3')) return 'faq_titre'; return 'autre'; } // ============= GÉNÉRATION SÉQUENTIELLE - ADAPTÉE ============= async function generateAllContent(elements, csvData, xmlTemplate) { await logSh(`Début génération pour ${elements.length} éléments`, 'INFO'); const generatedContent = {}; for (let index = 0; index < elements.length; index++) { const element = elements[index]; try { await logSh(`Élément ${index + 1}/${elements.length}: ${element.name}`, 'DEBUG'); const prompt = createPromptForElement(element, csvData); await logSh(`Prompt créé: ${prompt}`, 'DEBUG'); // 🔄 NODE.JS : Import callOpenAI depuis LLM manager const { callLLM } = require('./LLMManager'); const content = await callLLM('openai', prompt, {}, csvData.personality); await logSh(`Contenu reçu: ${content}`, 'DEBUG'); generatedContent[element.originalTag] = content; // 🔄 NODE.JS : Pas de Utilities.sleep(), les appels API gèrent leur rate limiting } catch (error) { await logSh(`ERREUR élément ${element.name}: ${error.toString()}`, 'ERROR'); generatedContent[element.originalTag] = `[Erreur génération: ${element.name}]`; } } await logSh(`Génération terminée. ${Object.keys(generatedContent).length} éléments`, 'INFO'); return generatedContent; } // ============= PARSING STRUCTURE - IDENTIQUE ============= function parseElementStructure(element) { // NETTOYER le nom : enlever , , {{...}}, {...} let cleanName = element.name .replace(/<\/?strong>/g, '') // ← ENLEVER .replace(/\{\{[^}]*\}\}/g, '') // Enlever {{MC0}} .replace(/\{[^}]*\}/g, ''); // Enlever {instructions} const parts = cleanName.split('_'); return { type: parts[0], level: parts[1], indices: parts.slice(2).map(Number), hierarchyPath: parts.slice(1).join('_'), originalElement: element, variables: element.variables || [], instructions: element.instructions }; } // ============= HIÉRARCHIE INTELLIGENTE - ADAPTÉE ============= async function buildSmartHierarchy(elements) { const hierarchy = {}; elements.forEach(element => { const structure = parseElementStructure(element); const path = structure.hierarchyPath; if (!hierarchy[path]) { hierarchy[path] = { title: null, text: null, questions: [], children: {} }; } // Associer intelligemment if (structure.type === 'Titre') { hierarchy[path].title = structure; // Tout l'objet avec variables + instructions } else if (structure.type === 'Txt') { hierarchy[path].text = structure; } else if (structure.type === 'Intro') { hierarchy[path].text = structure; } else if (structure.type === 'Faq') { hierarchy[path].questions.push(structure); } }); // ← LIGNE COMPILÉE const mappingSummary = Object.keys(hierarchy).map(path => { const section = hierarchy[path]; return `${path}:[T:${section.title ? '✓' : '✗'} Txt:${section.text ? '✓' : '✗'} FAQ:${section.questions.length}]`; }).join(' | '); await logSh('Correspondances: ' + mappingSummary, 'DEBUG'); return hierarchy; } // ============= PARSERS RÉPONSES - ADAPTÉS ============= async function parseTitlesResponse(response, allTitles) { const results = {}; // Utiliser regex pour extraire [TAG] contenu const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs; let match; while ((match = regex.exec(response)) !== null) { const tag = match[1].trim(); const content = match[2].trim(); // Nettoyer le contenu (enlever # et balises HTML si présentes) const cleanContent = content .replace(/^#+\s*/, '') // Enlever # du début .replace(/<\/?[^>]+(>|$)/g, ""); // Enlever balises HTML results[`|${tag}|`] = cleanContent; await logSh(`✓ Titre parsé [${tag}]: "${cleanContent}"`, 'DEBUG'); } // Fallback si parsing échoue if (Object.keys(results).length === 0) { await logSh('Parsing titres échoué, fallback ligne par ligne', 'WARNING'); const lines = response.split('\n').filter(line => line.trim()); allTitles.forEach((titleInfo, index) => { if (lines[index]) { results[titleInfo.tag] = lines[index].trim(); } }); } return results; } async function parseTextsResponse(response, allTexts) { const results = {}; await logSh('Parsing réponse textes avec vrais tags...', 'DEBUG'); // Utiliser regex pour extraire [TAG] contenu avec les vrais noms const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs; let match; while ((match = regex.exec(response)) !== null) { const tag = match[1].trim(); const content = match[2].trim(); // Nettoyer le contenu const cleanContent = content.replace(/^#+\s*/, '').replace(/<\/?[^>]+(>|$)/g, ""); results[`|${tag}|`] = cleanContent; await logSh(`✓ Texte parsé [${tag}]: "${cleanContent}"`, 'DEBUG'); } // Fallback si parsing échoue - mapper par position if (Object.keys(results).length === 0) { await logSh('Parsing textes échoué, fallback ligne par ligne', 'WARNING'); const lines = response.split('\n') .map(line => line.trim()) .filter(line => line.length > 0 && !line.startsWith('[')); for (let index = 0; index < allTexts.length; index++) { const textInfo = allTexts[index]; if (index < lines.length) { let content = lines[index]; content = content.replace(/^\d+\.\s*/, ''); // Enlever "1. " si présent results[textInfo.tag] = content; await logSh(`✓ Texte fallback ${index + 1} → ${textInfo.tag}: "${content}"`, 'DEBUG'); } else { await logSh(`✗ Pas assez de lignes pour ${textInfo.tag}`, 'WARNING'); results[textInfo.tag] = `[Texte manquant ${index + 1}]`; } } } return results; } // ============= PARSER FAQ SPÉCIALISÉ - ADAPTÉ ============= async function parseFAQPairsResponse(response, faqPairs) { const results = {}; await logSh('Parsing réponse paires FAQ...', 'DEBUG'); // Parser avec regex pour capturer question + réponse const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs; let match; const parsedItems = {}; while ((match = regex.exec(response)) !== null) { const tag = match[1].trim(); const content = match[2].trim(); const cleanContent = content.replace(/^#+\s*/, '').replace(/<\/?[^>]+(>|$)/g, ""); parsedItems[tag] = cleanContent; await logSh(`✓ Item FAQ parsé [${tag}]: "${cleanContent}"`, 'DEBUG'); } // Mapper aux tags originaux avec | Object.keys(parsedItems).forEach(cleanTag => { const content = parsedItems[cleanTag]; results[`|${cleanTag}|`] = content; }); // Vérification de cohérence paires let pairsCompletes = 0; for (const pair of faqPairs) { const hasQuestion = results[pair.question.tag]; const hasAnswer = results[pair.answer.tag]; if (hasQuestion && hasAnswer) { pairsCompletes++; await logSh(`✓ Paire FAQ ${pair.number} complète: Q+R`, 'DEBUG'); } else { await logSh(`⚠ Paire FAQ ${pair.number} incomplète: Q=${!!hasQuestion} R=${!!hasAnswer}`, 'WARNING'); } } await logSh(`${pairsCompletes}/${faqPairs.length} paires FAQ complètes`, 'INFO'); // FATAL si paires FAQ manquantes if (pairsCompletes < faqPairs.length) { const manquantes = faqPairs.length - pairsCompletes; await logSh(`❌ FATAL: ${manquantes} paires FAQ manquantes sur ${faqPairs.length}`, 'ERROR'); throw new Error(`FATAL: Génération FAQ incomplète (${manquantes}/${faqPairs.length} manquantes) - arrêt du workflow`); } return results; } async function parseOtherElementsResponse(response, allOtherElements) { const results = {}; await logSh('Parsing réponse autres éléments...', 'DEBUG'); const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs; let match; while ((match = regex.exec(response)) !== null) { const tag = match[1].trim(); const content = match[2].trim(); const cleanContent = content.replace(/^#+\s*/, '').replace(/<\/?[^>]+(>|$)/g, ""); results[`|${tag}|`] = cleanContent; await logSh(`✓ Autre élément parsé [${tag}]: "${cleanContent}"`, 'DEBUG'); } // Fallback si parsing partiel if (Object.keys(results).length < allOtherElements.length) { await logSh('Parsing autres éléments partiel, complétion fallback', 'WARNING'); const lines = response.split('\n') .map(line => line.trim()) .filter(line => line.length > 0 && !line.startsWith('[')); allOtherElements.forEach((element, index) => { if (!results[element.tag] && lines[index]) { results[element.tag] = lines[index]; } }); } return results; } // ============= HELPER FUNCTIONS - ADAPTÉES ============= function createPromptForElement(element, csvData) { // Cette fonction sera probablement définie dans content-generation.js // Pour l'instant, retour basique return `Génère du contenu pour ${element.type}: ${element.resolvedContent}`; } // 🔄 NODE.JS EXPORTS module.exports = { extractElements, resolveVariablesContent, getElementType, generateAllContent, parseElementStructure, buildSmartHierarchy, parseTitlesResponse, parseTextsResponse, parseFAQPairsResponse, parseOtherElementsResponse, createPromptForElement }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/MissingKeywords.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: MissingKeywords.js - Version Node.js // Description: Génération automatique des mots-clés manquants // ======================================== const { logSh } = require('./ErrorReporting'); const { callLLM } = require('./LLMManager'); /** * Génère automatiquement les mots-clés manquants pour les éléments non définis * @param {Array} elements - Liste des éléments extraits * @param {Object} csvData - Données CSV avec personnalité * @returns {Object} Éléments mis à jour avec nouveaux mots-clés */ async function generateMissingKeywords(elements, csvData) { logSh('>>> GÉNÉRATION MOTS-CLÉS MANQUANTS <<<', 'INFO'); // 1. IDENTIFIER tous les éléments manquants const missingElements = []; elements.forEach(element => { if (element.resolvedContent.includes('non défini') || element.resolvedContent.includes('non résolu') || element.resolvedContent.trim() === '') { missingElements.push({ tag: element.originalTag, name: element.name, type: element.type, currentContent: element.resolvedContent, context: getElementContext(element, elements, csvData) }); } }); if (missingElements.length === 0) { logSh('Aucun mot-clé manquant détecté', 'INFO'); return {}; } logSh(`${missingElements.length} mots-clés manquants détectés`, 'INFO'); // 2. ANALYSER le contexte global disponible const contextAnalysis = analyzeAvailableContext(elements, csvData); // 3. GÉNÉRER tous les manquants en UN SEUL appel IA const generatedKeywords = await callOpenAIForMissingKeywords(missingElements, contextAnalysis, csvData); // 4. METTRE À JOUR les éléments avec les nouveaux mots-clés const updatedElements = updateElementsWithKeywords(elements, generatedKeywords); logSh(`Mots-clés manquants générés: ${Object.keys(generatedKeywords).length}`, 'INFO'); return updatedElements; } /** * Analyser le contexte disponible pour guider la génération * @param {Array} elements - Tous les éléments * @param {Object} csvData - Données CSV * @returns {Object} Analyse contextuelle */ function analyzeAvailableContext(elements, csvData) { const availableKeywords = []; const availableContent = []; // Récupérer tous les mots-clés/contenu déjà disponibles elements.forEach(element => { if (element.resolvedContent && !element.resolvedContent.includes('non défini') && !element.resolvedContent.includes('non résolu') && element.resolvedContent.trim() !== '') { if (element.type.includes('titre')) { availableKeywords.push(element.resolvedContent); } else { availableContent.push(element.resolvedContent.substring(0, 100)); } } }); return { mainKeyword: csvData.mc0, mainTitle: csvData.t0, availableKeywords: availableKeywords, availableContent: availableContent, theme: csvData.mc0, // Thème principal businessContext: "Autocollant.fr - signalétique personnalisée, plaques" }; } /** * Obtenir le contexte spécifique d'un élément * @param {Object} element - Élément à analyser * @param {Array} allElements - Tous les éléments * @param {Object} csvData - Données CSV * @returns {Object} Contexte de l'élément */ function getElementContext(element, allElements, csvData) { const context = { elementType: element.type, hierarchyLevel: element.name, nearbyElements: [] }; // Trouver les éléments proches dans la hiérarchie const elementParts = element.name.split('_'); if (elementParts.length >= 2) { const baseLevel = elementParts.slice(0, 2).join('_'); // Ex: "Titre_H3" allElements.forEach(otherElement => { if (otherElement.name.startsWith(baseLevel) && otherElement.resolvedContent && !otherElement.resolvedContent.includes('non défini')) { context.nearbyElements.push(otherElement.resolvedContent); } }); } return context; } /** * Appel IA pour générer tous les mots-clés manquants en un seul batch * @param {Array} missingElements - Éléments manquants * @param {Object} contextAnalysis - Analyse contextuelle * @param {Object} csvData - Données CSV avec personnalité * @returns {Object} Mots-clés générés */ async function callOpenAIForMissingKeywords(missingElements, contextAnalysis, csvData) { const personality = csvData.personality; let prompt = `Tu es ${personality.nom} (${personality.description}). Style: ${personality.style} MISSION: GÉNÈRE ${missingElements.length} MOTS-CLÉS/EXPRESSIONS MANQUANTS pour ${contextAnalysis.mainKeyword} CONTEXTE: - Sujet: ${contextAnalysis.mainKeyword} - Entreprise: Autocollant.fr (signalétique) - Mots-clés existants: ${contextAnalysis.availableKeywords.slice(0, 3).join(', ')} ÉLÉMENTS MANQUANTS: `; missingElements.forEach((missing, index) => { prompt += `${index + 1}. [${missing.name}] → Mot-clé SEO\n`; }); prompt += `\nCONSIGNES: - Thème: ${contextAnalysis.mainKeyword} - Mots-clés SEO naturels - Varie les termes - Évite répétitions FORMAT: [${missingElements[0].name}] mot-clé [${missingElements[1] ? missingElements[1].name : 'exemple'}] mot-clé etc...`; try { logSh('Génération mots-clés manquants...', 'DEBUG'); // Utilisation du LLM Manager avec fallback const response = await callLLM('openai', prompt, { temperature: 0.7, maxTokens: 2000 }, personality); // Parser la réponse const generatedKeywords = parseMissingKeywordsResponse(response, missingElements); return generatedKeywords; } catch (error) { logSh(`❌ FATAL: Génération mots-clés manquants échouée: ${error}`, 'ERROR'); throw new Error(`FATAL: Génération mots-clés LLM impossible - arrêt du workflow: ${error}`); } } /** * Parser la réponse IA pour extraire les mots-clés générés * @param {string} response - Réponse de l'IA * @param {Array} missingElements - Éléments manquants * @returns {Object} Mots-clés parsés */ function parseMissingKeywordsResponse(response, missingElements) { const results = {}; const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs; let match; while ((match = regex.exec(response)) !== null) { const elementName = match[1].trim(); const generatedKeyword = match[2].trim(); results[elementName] = generatedKeyword; logSh(`✓ Mot-clé généré [${elementName}]: "${generatedKeyword}"`, 'DEBUG'); } // FATAL si parsing partiel if (Object.keys(results).length < missingElements.length) { const manquants = missingElements.length - Object.keys(results).length; logSh(`❌ FATAL: Parsing mots-clés partiel - ${manquants}/${missingElements.length} manquants`, 'ERROR'); throw new Error(`FATAL: Parsing mots-clés incomplet (${manquants}/${missingElements.length} manquants) - arrêt du workflow`); } return results; } /** * Mettre à jour les éléments avec les nouveaux mots-clés générés * @param {Array} elements - Éléments originaux * @param {Object} generatedKeywords - Nouveaux mots-clés * @returns {Array} Éléments mis à jour */ function updateElementsWithKeywords(elements, generatedKeywords) { const updatedElements = elements.map(element => { const newKeyword = generatedKeywords[element.name]; if (newKeyword) { return { ...element, resolvedContent: newKeyword }; } return element; }); logSh('Éléments mis à jour avec nouveaux mots-clés', 'INFO'); return updatedElements; } // Exports CommonJS module.exports = { generateMissingKeywords }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/trace.js │ └────────────────────────────────────────────────────────────────────┘ */ // lib/trace.js const { AsyncLocalStorage } = require('node:async_hooks'); const { randomUUID } = require('node:crypto'); const { logSh } = require('./ErrorReporting'); const als = new AsyncLocalStorage(); function now() { return performance.now(); } function dur(ms) { if (ms < 1e3) return `${ms.toFixed(1)}ms`; const s = ms / 1e3; return s < 60 ? `${s.toFixed(2)}s` : `${(s/60).toFixed(2)}m`; } class Span { constructor({ name, parent = null, attrs = {} }) { this.id = randomUUID(); this.name = name; this.parent = parent; this.children = []; this.attrs = attrs; this.start = now(); this.end = null; this.status = 'ok'; this.error = null; } pathNames() { const names = []; let cur = this; while (cur) { names.unshift(cur.name); cur = cur.parent; } return names.join(' > '); } finish() { this.end = now(); } duration() { return (this.end ?? now()) - this.start; } } class Tracer { constructor() { this.rootSpans = []; } current() { return als.getStore(); } async startSpan(name, attrs = {}) { const parent = this.current(); const span = new Span({ name, parent, attrs }); if (parent) parent.children.push(span); else this.rootSpans.push(span); // Formater les paramètres pour affichage const paramsStr = this.formatParams(attrs); await logSh(`▶ ${name}${paramsStr}`, 'TRACE'); return span; } async run(name, fn, attrs = {}) { const parent = this.current(); const span = await this.startSpan(name, attrs); return await als.run(span, async () => { try { const res = await fn(); span.finish(); const paramsStr = this.formatParams(span.attrs); await logSh(`✔ ${name}${paramsStr} (${dur(span.duration())})`, 'TRACE'); return res; } catch (err) { span.status = 'error'; span.error = { message: err?.message, stack: err?.stack }; span.finish(); const paramsStr = this.formatParams(span.attrs); await logSh(`✖ ${name}${paramsStr} FAILED (${dur(span.duration())})`, 'ERROR'); await logSh(`Stack trace: ${span.error.message}`, 'ERROR'); if (span.error.stack) { const stackLines = span.error.stack.split('\n').slice(1, 6); // Première 5 lignes du stack for (const line of stackLines) { await logSh(` ${line.trim()}`, 'ERROR'); } } throw err; } }); } async event(msg, extra = {}) { const span = this.current(); const data = { trace: true, evt: 'span.event', ...extra }; if (span) { data.span = span.id; data.path = span.pathNames(); data.since_ms = +( (now() - span.start).toFixed(1) ); } await logSh(`• ${msg}`, 'TRACE'); } async annotate(fields = {}) { const span = this.current(); if (span) Object.assign(span.attrs, fields); await logSh('… annotate', 'TRACE'); } formatParams(attrs = {}) { const params = Object.entries(attrs) .filter(([key, value]) => value !== undefined && value !== null) .map(([key, value]) => { // Tronquer les valeurs trop longues const strValue = String(value); const truncated = strValue.length > 50 ? strValue.substring(0, 47) + '...' : strValue; return `${key}=${truncated}`; }); return params.length > 0 ? `(${params.join(', ')})` : ''; } printSummary() { const lines = []; const draw = (node, depth = 0) => { const pad = ' '.repeat(depth); const icon = node.status === 'error' ? '✖' : '✔'; lines.push(`${pad}${icon} ${node.name} (${dur(node.duration())})`); if (Object.keys(node.attrs ?? {}).length) { lines.push(`${pad} attrs: ${JSON.stringify(node.attrs)}`); } for (const ch of node.children) draw(ch, depth + 1); if (node.status === 'error' && node.error?.message) { lines.push(`${pad} error: ${node.error.message}`); if (node.error.stack) { const stackLines = String(node.error.stack || '').split('\n').slice(1, 4).map(s => s.trim()); if (stackLines.length) { lines.push(`${pad} stack:`); stackLines.forEach(line => { if (line) lines.push(`${pad} ${line}`); }); } } } }; for (const r of this.rootSpans) draw(r, 0); const summary = lines.join('\n'); logSh(`\n—— TRACE SUMMARY ——\n${summary}\n—— END TRACE ——`, 'INFO'); return summary; } } const tracer = new Tracer(); module.exports = { Span, Tracer, tracer }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/SelectiveEnhancement.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: SelectiveEnhancement.js - Node.js Version // Description: Enhancement par batch pour éviter timeouts // ======================================== const { callLLM } = require('./LLMManager'); const { logSh } = require('./ErrorReporting'); const { tracer } = require('./trace.js'); const { selectMultiplePersonalitiesWithAI, getPersonalities } = require('./BrainConfig'); // Utilitaire pour les délais function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * NOUVELLE APPROCHE - Multi-Personnalités Batch Enhancement * 4 personnalités différentes utilisées dans le pipeline pour maximum d'anti-détection */ async function generateWithBatchEnhancement(hierarchy, csvData) { const totalElements = Object.keys(hierarchy).length; // NOUVEAU: Sélection de 4 personnalités complémentaires const personalities = await tracer.run('SelectiveEnhancement.selectMultiplePersonalities()', async () => { const allPersonalities = await getPersonalities(); const selectedPersonalities = await selectMultiplePersonalitiesWithAI(csvData.mc0, csvData.t0, allPersonalities); await tracer.event(`4 personnalités sélectionnées: ${selectedPersonalities.map(p => p.nom).join(', ')}`); return selectedPersonalities; }, { mc0: csvData.mc0, t0: csvData.t0 }); await tracer.annotate({ totalElements, personalities: personalities.map(p => `${p.nom}(${p.style})`).join(', '), mc0: csvData.mc0 }); // ÉTAPE 1 : Génération base avec IA configurée + Personnalité 1 const baseContents = await tracer.run('SelectiveEnhancement.generateAllContentBase()', async () => { const csvDataWithPersonality1 = { ...csvData, personality: personalities[0] }; const aiProvider1 = personalities[0].aiEtape1Base; const result = await generateAllContentBase(hierarchy, csvDataWithPersonality1, aiProvider1); await tracer.event(`${Object.keys(result).length} éléments générés avec ${personalities[0].nom} via ${aiProvider1.toUpperCase()}`); return result; }, { hierarchyElements: Object.keys(hierarchy).length, personality1: personalities[0].nom, llmProvider: personalities[0].aiEtape1Base, mc0: csvData.mc0 }); // ÉTAPE 2 : Enhancement technique avec IA configurée + Personnalité 2 const technicalEnhanced = await tracer.run('SelectiveEnhancement.enhanceAllTechnicalTerms()', async () => { const csvDataWithPersonality2 = { ...csvData, personality: personalities[1] }; const aiProvider2 = personalities[1].aiEtape2Technique; const result = await enhanceAllTechnicalTerms(baseContents, csvDataWithPersonality2, aiProvider2); const enhancedCount = Object.keys(result).filter(k => result[k] !== baseContents[k]).length; await tracer.event(`${enhancedCount}/${Object.keys(result).length} éléments techniques améliorés avec ${personalities[1].nom} via ${aiProvider2.toUpperCase()}`); return result; }, { baseElements: Object.keys(baseContents).length, personality2: personalities[1].nom, llmProvider: personalities[1].aiEtape2Technique, mc0: csvData.mc0 }); // ÉTAPE 3 : Enhancement transitions avec IA configurée + Personnalité 3 const transitionsEnhanced = await tracer.run('SelectiveEnhancement.enhanceAllTransitions()', async () => { const csvDataWithPersonality3 = { ...csvData, personality: personalities[2] }; const aiProvider3 = personalities[2].aiEtape3Transitions; const result = await enhanceAllTransitions(technicalEnhanced, csvDataWithPersonality3, aiProvider3); const enhancedCount = Object.keys(result).filter(k => result[k] !== technicalEnhanced[k]).length; await tracer.event(`${enhancedCount}/${Object.keys(result).length} transitions fluidifiées avec ${personalities[2].nom} via ${aiProvider3.toUpperCase()}`); return result; }, { technicalElements: Object.keys(technicalEnhanced).length, personality3: personalities[2].nom, llmProvider: personalities[2].aiEtape3Transitions }); // ÉTAPE 4 : Enhancement style avec IA configurée + Personnalité 4 const finalContents = await tracer.run('SelectiveEnhancement.enhanceAllPersonalityStyle()', async () => { const csvDataWithPersonality4 = { ...csvData, personality: personalities[3] }; const aiProvider4 = personalities[3].aiEtape4Style; const result = await enhanceAllPersonalityStyle(transitionsEnhanced, csvDataWithPersonality4, aiProvider4); const enhancedCount = Object.keys(result).filter(k => result[k] !== transitionsEnhanced[k]).length; const avgWords = Math.round(Object.values(result).reduce((acc, content) => acc + content.split(' ').length, 0) / Object.keys(result).length); await tracer.event(`${enhancedCount}/${Object.keys(result).length} éléments stylisés avec ${personalities[3].nom} via ${aiProvider4.toUpperCase()}`, { avgWordsPerElement: avgWords }); return result; }, { transitionElements: Object.keys(transitionsEnhanced).length, personality4: personalities[3].nom, llmProvider: personalities[3].aiEtape4Style }); // Log final du DNA Mixing réussi avec IA configurables const aiChain = personalities.map((p, i) => `${p.aiEtape1Base || p.aiEtape2Technique || p.aiEtape3Transitions || p.aiEtape4Style}`.toUpperCase()).join(' → '); logSh(`✅ DNA MIXING MULTI-PERSONNALITÉS TERMINÉ:`, 'INFO'); logSh(` 🎭 4 personnalités utilisées: ${personalities.map(p => p.nom).join(' → ')}`, 'INFO'); logSh(` 🤖 IA configurées: ${personalities[0].aiEtape1Base.toUpperCase()} → ${personalities[1].aiEtape2Technique.toUpperCase()} → ${personalities[2].aiEtape3Transitions.toUpperCase()} → ${personalities[3].aiEtape4Style.toUpperCase()}`, 'INFO'); logSh(` 📝 ${Object.keys(finalContents).length} éléments avec style hybride généré`, 'INFO'); return finalContents; } /** * ÉTAPE 1 - Génération base TOUS éléments avec IA configurable */ async function generateAllContentBase(hierarchy, csvData, aiProvider) { logSh('🔍 === DEBUG GÉNÉRATION BASE ===', 'DEBUG'); // Debug: logger la hiérarchie complète logSh(`🔍 Hiérarchie reçue: ${Object.keys(hierarchy).length} sections`, 'DEBUG'); Object.keys(hierarchy).forEach((path, i) => { const section = hierarchy[path]; logSh(`🔍 Section ${i+1} [${path}]:`, 'DEBUG'); logSh(`🔍 - title: ${section.title ? section.title.originalElement?.originalTag : 'AUCUN'}`, 'DEBUG'); logSh(`🔍 - text: ${section.text ? section.text.originalElement?.originalTag : 'AUCUN'}`, 'DEBUG'); logSh(`🔍 - questions: ${section.questions?.length || 0}`, 'DEBUG'); }); const allElements = collectAllElements(hierarchy); logSh(`🔍 Éléments collectés: ${allElements.length}`, 'DEBUG'); // Debug: logger tous les éléments collectés allElements.forEach((element, i) => { logSh(`🔍 Élément ${i+1}: tag="${element.tag}", type="${element.type}"`, 'DEBUG'); }); // NOUVELLE LOGIQUE : SÉPARER PAIRES FAQ ET AUTRES ÉLÉMENTS const results = {}; logSh(`🔍 === GÉNÉRATION INTELLIGENTE DE ${allElements.length} ÉLÉMENTS ===`, 'DEBUG'); logSh(`🔍 Ordre respecté: ${allElements.map(el => el.tag.replace(/\|/g, '')).join(' → ')}`, 'DEBUG'); // 1. IDENTIFIER les paires FAQ const { faqPairs, otherElements } = separateFAQPairsAndOthers(allElements); logSh(`🔍 ${faqPairs.length} paires FAQ trouvées, ${otherElements.length} autres éléments`, 'INFO'); // 2. GÉNÉRER les autres éléments EN BATCH ORDONNÉ (titres d'abord, puis textes avec contexte) const groupedElements = groupElementsByType(otherElements); // ORDRE DE GÉNÉRATION : TITRES → TEXTES → INTRO → AUTRES const orderedTypes = ['titre', 'texte', 'intro']; for (const type of orderedTypes) { const elements = groupedElements[type]; if (!elements || elements.length === 0) continue; // DÉCOUPER EN CHUNKS DE MAX 4 ÉLÉMENTS POUR ÉVITER TIMEOUTS const chunks = chunkArray(elements, 4); logSh(`🚀 BATCH ${type.toUpperCase()}: ${elements.length} éléments en ${chunks.length} chunks`, 'INFO'); for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; logSh(` Chunk ${chunkIndex + 1}/${chunks.length}: ${chunk.length} éléments`, 'DEBUG'); try { // Passer les résultats déjà générés pour contexte (titres → textes) const batchPrompt = createBatchBasePrompt(chunk, type, csvData, results); const batchResponse = await callLLM(aiProvider, batchPrompt, { temperature: 0.7, maxTokens: 2000 * chunk.length }, csvData.personality); const batchResults = parseBatchResponse(batchResponse, chunk); Object.assign(results, batchResults); logSh(`✅ Chunk ${chunkIndex + 1}: ${Object.keys(batchResults).length}/${chunk.length} éléments générés`, 'INFO'); } catch (error) { logSh(`❌ FATAL: Chunk ${chunkIndex + 1} de ${type} échoué: ${error.message}`, 'ERROR'); throw new Error(`FATAL: Génération chunk ${chunkIndex + 1} de ${type} échouée - arrêt du workflow: ${error.message}`); } // Délai entre chunks pour éviter rate limiting if (chunkIndex < chunks.length - 1) { await sleep(1500); } } logSh(`✅ BATCH ${type.toUpperCase()} COMPLET: ${elements.length} éléments générés en ${chunks.length} chunks`, 'INFO'); } // TRAITER les types restants (autres que titre/texte/intro) for (const [type, elements] of Object.entries(groupedElements)) { if (orderedTypes.includes(type) || elements.length === 0) continue; // DÉCOUPER EN CHUNKS DE MAX 4 ÉLÉMENTS POUR ÉVITER TIMEOUTS const chunks = chunkArray(elements, 4); logSh(`🚀 BATCH ${type.toUpperCase()}: ${elements.length} éléments en ${chunks.length} chunks`, 'INFO'); for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; logSh(` Chunk ${chunkIndex + 1}/${chunks.length}: ${chunk.length} éléments`, 'DEBUG'); try { const batchPrompt = createBatchBasePrompt(chunk, type, csvData, results); const batchResponse = await callLLM(aiProvider, batchPrompt, { temperature: 0.7, maxTokens: 2000 * chunk.length }, csvData.personality); const batchResults = parseBatchResponse(batchResponse, chunk); Object.assign(results, batchResults); logSh(`✅ Chunk ${chunkIndex + 1}: ${Object.keys(batchResults).length}/${chunk.length} éléments générés`, 'INFO'); } catch (error) { logSh(`❌ FATAL: Chunk ${chunkIndex + 1} de ${type} échoué: ${error.message}`, 'ERROR'); throw new Error(`FATAL: Génération chunk ${chunkIndex + 1} de ${type} échouée - arrêt du workflow: ${error.message}`); } // Délai entre chunks if (chunkIndex < chunks.length - 1) { await sleep(1500); } } logSh(`✅ BATCH ${type.toUpperCase()} COMPLET: ${elements.length} éléments générés en ${chunks.length} chunks`, 'INFO'); } // 3. GÉNÉRER les paires FAQ ensemble (RESTAURÉ depuis .gs) if (faqPairs.length > 0) { logSh(`🔍 === GÉNÉRATION PAIRES FAQ (${faqPairs.length} paires) ===`, 'INFO'); const faqResults = await generateFAQPairsRestored(faqPairs, csvData, aiProvider); Object.assign(results, faqResults); } logSh(`🔍 === RÉSULTATS FINAUX GÉNÉRATION BASE ===`, 'DEBUG'); logSh(`🔍 Total généré: ${Object.keys(results).length} éléments`, 'DEBUG'); Object.keys(results).forEach(tag => { logSh(`🔍 [${tag}]: "${results[tag]}"`, 'DEBUG'); }); return results; } /** * ÉTAPE 2 - Enhancement technique ÉLÉMENT PAR ÉLÉMENT avec IA configurable * NOUVEAU : Traitement individuel pour fiabilité maximale et debug précis */ async function enhanceAllTechnicalTerms(baseContents, csvData, aiProvider) { logSh('🔧 === DÉBUT ENHANCEMENT TECHNIQUE ===', 'INFO'); logSh('Enhancement technique BATCH TOTAL...', 'DEBUG'); const allElements = Object.keys(baseContents); if (allElements.length === 0) { logSh('⚠️ Aucun élément à analyser techniquement', 'WARNING'); return baseContents; } const analysisStart = Date.now(); logSh(`📊 Analyse démarrée: ${allElements.length} éléments à examiner`, 'INFO'); try { // ÉTAPE 1 : Extraction batch TOUS les termes techniques (1 seul appel) logSh(`🔍 Analyse technique batch: ${allElements.length} éléments`, 'INFO'); const technicalAnalysis = await extractAllTechnicalTermsBatch(baseContents, csvData, aiProvider); const analysisEnd = Date.now(); // ÉTAPE 2 : Enhancement batch TOUS les éléments qui en ont besoin (1 seul appel) const elementsNeedingEnhancement = technicalAnalysis.filter(item => item.needsEnhancement); logSh(`📋 Analyse terminée (${analysisEnd - analysisStart}ms):`, 'INFO'); logSh(` • ${elementsNeedingEnhancement.length}/${allElements.length} éléments nécessitent enhancement`, 'INFO'); if (elementsNeedingEnhancement.length === 0) { logSh('✅ Aucun élément ne nécessite enhancement technique - contenu déjà optimal', 'INFO'); return baseContents; } // Log détaillé des éléments à améliorer elementsNeedingEnhancement.forEach((item, i) => { logSh(` ${i+1}. [${item.tag}]: ${item.technicalTerms.join(', ')}`, 'DEBUG'); }); const enhancementStart = Date.now(); logSh(`🔧 Enhancement technique: ${elementsNeedingEnhancement.length}/${allElements.length} éléments`, 'INFO'); const enhancedContents = await enhanceAllElementsTechnicalBatch(elementsNeedingEnhancement, csvData, aiProvider); const enhancementEnd = Date.now(); // ÉTAPE 3 : Merger résultats const results = { ...baseContents }; let actuallyEnhanced = 0; Object.keys(enhancedContents).forEach(tag => { if (enhancedContents[tag] !== baseContents[tag]) { results[tag] = enhancedContents[tag]; actuallyEnhanced++; } }); logSh(`⚡ Enhancement terminé (${enhancementEnd - enhancementStart}ms):`, 'INFO'); logSh(` • ${actuallyEnhanced} éléments réellement améliorés`, 'INFO'); logSh(` • Termes intégrés: dibond, impression UV, fraisage, etc.`, 'DEBUG'); logSh(`✅ Enhancement technique terminé avec succès`, 'INFO'); return results; } catch (error) { const analysisTotal = Date.now() - analysisStart; logSh(`❌ FATAL: Enhancement technique échoué après ${analysisTotal}ms`, 'ERROR'); logSh(`❌ Message: ${error.message}`, 'ERROR'); throw new Error(`FATAL: Enhancement technique impossible - arrêt du workflow: ${error.message}`); } } /** * Analyser un seul élément pour détecter les termes techniques */ async function analyzeSingleElementTechnicalTerms(tag, content, csvData, aiProvider) { const prompt = `MISSION: Analyser ce contenu et déterminer s'il contient des termes techniques. CONTEXTE: ${csvData.mc0} - Secteur: signalétique/impression CONTENU À ANALYSER: TAG: ${tag} CONTENU: "${content}" CONSIGNES: - Cherche UNIQUEMENT des vrais termes techniques métier/industrie - Évite mots génériques (qualité, service, pratique, personnalisé, etc.) - Focus: matériaux, procédés, normes, dimensions, technologies spécifiques EXEMPLES VALIDES: dibond, impression UV, fraisage CNC, épaisseur 3mm, aluminium brossé, anodisation EXEMPLES INVALIDES: durable, pratique, personnalisé, moderne, esthétique, haute performance RÉPONSE REQUISE: - Si termes techniques trouvés: "OUI - termes: [liste des termes séparés par virgules]" - Si aucun terme technique: "NON" EXEMPLE: OUI - termes: aluminium composite, impression numérique, gravure laser`; try { const response = await callLLM(aiProvider, prompt, { temperature: 0.3 }); if (response.toUpperCase().startsWith('OUI')) { // Extraire les termes de la réponse const termsMatch = response.match(/termes:\s*(.+)/i); const terms = termsMatch ? termsMatch[1].trim() : ''; logSh(`✅ [${tag}] Termes techniques détectés: ${terms}`, 'DEBUG'); return true; } else { logSh(`⏭️ [${tag}] Pas de termes techniques`, 'DEBUG'); return false; } } catch (error) { logSh(`❌ ERREUR analyse ${tag}: ${error.message}`, 'ERROR'); return false; // En cas d'erreur, on skip l'enhancement } } /** * Enhancer un seul élément techniquement */ async function enhanceSingleElementTechnical(tag, content, csvData, aiProvider) { const prompt = `MISSION: Améliore ce contenu en intégrant des termes techniques précis. CONTEXTE: ${csvData.mc0} - Secteur: signalétique/impression CONTENU À AMÉLIORER: TAG: ${tag} CONTENU: "${content}" OBJECTIFS: - Remplace les termes génériques par des termes techniques précis - Ajoute des spécifications techniques réalistes - Maintient le même style et longueur - Intègre naturellement: matériaux (dibond, aluminium composite), procédés (impression UV, gravure laser), dimensions, normes EXEMPLE DE TRANSFORMATION: "matériaux haute performance" → "dibond 3mm ou aluminium composite" "impression moderne" → "impression UV haute définition" "fixation solide" → "fixation par chevilles inox Ø6mm" CONTRAINTES: - GARDE la même structure - MÊME longueur approximative - Style cohérent avec l'original - RÉPONDS DIRECTEMENT par le contenu amélioré, sans préfixe`; try { const enhancedContent = await callLLM(aiProvider, prompt, { temperature: 0.7 }); return enhancedContent.trim(); } catch (error) { logSh(`❌ ERREUR enhancement ${tag}: ${error.message}`, 'ERROR'); return content; // En cas d'erreur, on retourne le contenu original } } // ANCIENNES FONCTIONS BATCH SUPPRIMÉES - REMPLACÉES PAR TRAITEMENT INDIVIDUEL /** * NOUVELLE FONCTION : Enhancement batch TOUS les éléments */ // FONCTION SUPPRIMÉE : enhanceAllElementsTechnicalBatch() - Remplacée par traitement individuel /** * ÉTAPE 3 - Enhancement transitions BATCH avec IA configurable */ async function enhanceAllTransitions(baseContents, csvData, aiProvider) { logSh('🔗 === DÉBUT ENHANCEMENT TRANSITIONS ===', 'INFO'); logSh('Enhancement transitions batch...', 'DEBUG'); const transitionStart = Date.now(); const allElements = Object.keys(baseContents); logSh(`📊 Analyse transitions: ${allElements.length} éléments à examiner`, 'INFO'); // Sélectionner éléments longs qui bénéficient d'amélioration transitions const transitionElements = []; let analyzedCount = 0; Object.keys(baseContents).forEach(tag => { const content = baseContents[tag]; analyzedCount++; if (content.length > 150) { const needsTransitions = analyzeTransitionNeed(content); logSh(` [${tag}]: ${content.length}c, transitions=${needsTransitions ? '✅' : '❌'}`, 'DEBUG'); if (needsTransitions) { transitionElements.push({ tag: tag, content: content }); } } else { logSh(` [${tag}]: ${content.length}c - trop court, ignoré`, 'DEBUG'); } }); logSh(`📋 Analyse transitions terminée:`, 'INFO'); logSh(` • ${analyzedCount} éléments analysés`, 'INFO'); logSh(` • ${transitionElements.length} nécessitent amélioration`, 'INFO'); if (transitionElements.length === 0) { logSh('✅ Pas d\'éléments nécessitant enhancement transitions - fluidité déjà optimale', 'INFO'); return baseContents; } logSh(`${transitionElements.length} éléments à améliorer (transitions)`, 'INFO'); const chunks = chunkArray(transitionElements, 6); // Plus petit pour Gemini const results = { ...baseContents }; for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; try { logSh(`Chunk transitions ${chunkIndex + 1}/${chunks.length} (${chunk.length} éléments)`, 'DEBUG'); const batchTransitionsPrompt = `MISSION: Améliore UNIQUEMENT les transitions et fluidité de ces contenus. CONTEXTE: Article SEO professionnel pour site web commercial PERSONNALITÉ: ${csvData.personality?.nom} (${csvData.personality?.style} adapté web) CONNECTEURS PRÉFÉRÉS: ${csvData.personality?.connecteursPref} CONTENUS: ${chunk.map((item, i) => `[${i + 1}] TAG: ${item.tag} "${item.content}"`).join('\n\n')} OBJECTIFS: - Connecteurs plus naturels et variés issus de: ${csvData.personality?.connecteursPref} - Transitions fluides entre idées - ÉVITE répétitions excessives ("franchement", "du coup", "vraiment", "par ailleurs") - Style cohérent ${csvData.personality?.style} CONTRAINTES STRICTES: - NE CHANGE PAS le fond du message - GARDE la même structure et longueur approximative - Améliore SEULEMENT la fluidité des transitions - RESPECTE le style ${csvData.personality?.nom} - RÉPONDS DIRECTEMENT PAR LE CONTENU AMÉLIORÉ, sans préfixe ni tag XML FORMAT DE RÉPONSE: [1] Contenu avec transitions améliorées selon ${csvData.personality?.nom} [2] Contenu avec transitions améliorées selon ${csvData.personality?.nom} etc...`; const improved = await callLLM(aiProvider, batchTransitionsPrompt, { temperature: 0.6, maxTokens: 2500 }, csvData.personality); const parsedImprovements = parseTransitionsBatchResponse(improved, chunk); Object.keys(parsedImprovements).forEach(tag => { results[tag] = parsedImprovements[tag]; }); } catch (error) { logSh(`❌ Erreur chunk transitions ${chunkIndex + 1}: ${error.message}`, 'ERROR'); } if (chunkIndex < chunks.length - 1) { await sleep(1500); } } return results; } /** * ÉTAPE 4 - Enhancement style personnalité BATCH avec IA configurable */ async function enhanceAllPersonalityStyle(baseContents, csvData, aiProvider) { const personality = csvData.personality; if (!personality) { logSh('Pas de personnalité, skip enhancement style', 'DEBUG'); return baseContents; } logSh(`Enhancement style ${personality.nom} batch...`, 'DEBUG'); // Tous les éléments bénéficient de l'adaptation personnalité const styleElements = Object.keys(baseContents).map(tag => ({ tag: tag, content: baseContents[tag] })); const chunks = chunkArray(styleElements, 8); const results = { ...baseContents }; for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; try { logSh(`Chunk style ${chunkIndex + 1}/${chunks.length} (${chunk.length} éléments)`, 'DEBUG'); const batchStylePrompt = `MISSION: Adapte UNIQUEMENT le style de ces contenus selon ${personality.nom}. CONTEXTE: Finalisation article SEO pour site e-commerce professionnel PERSONNALITÉ: ${personality.nom} DESCRIPTION: ${personality.description} STYLE CIBLE: ${personality.style} adapté au web professionnel VOCABULAIRE: ${personality.vocabulairePref} CONNECTEURS: ${personality.connecteursPref} NIVEAU TECHNIQUE: ${personality.niveauTechnique} LONGUEUR PHRASES: ${personality.longueurPhrases} CONTENUS À STYLISER: ${chunk.map((item, i) => `[${i + 1}] TAG: ${item.tag} "${item.content}"`).join('\n\n')} CONSIGNES STRICTES: - GARDE le même contenu informatif et technique - Adapte SEULEMENT le ton, les expressions et le vocabulaire selon ${personality.nom} - RESPECTE la longueur approximative (même nombre de mots ±20%) - ÉVITE les répétitions excessives ("franchement", "du coup", "vraiment") - VARIE les expressions et connecteurs selon: ${personality.connecteursPref} - Style ${personality.nom} reconnaissable mais NATUREL - RÉPONDS DIRECTEMENT PAR LE CONTENU STYLISÉ, sans préfixe ni tag XML - PAS de messages d'excuse ou d'incapacité FORMAT DE RÉPONSE: [1] Contenu stylisé selon ${personality.nom} (${personality.style}) [2] Contenu stylisé selon ${personality.nom} (${personality.style}) etc...`; const styled = await callLLM(aiProvider, batchStylePrompt, { temperature: 0.8, maxTokens: 3000 }, personality); const parsedStyles = parseStyleBatchResponse(styled, chunk); Object.keys(parsedStyles).forEach(tag => { results[tag] = parsedStyles[tag]; }); } catch (error) { logSh(`❌ Erreur chunk style ${chunkIndex + 1}: ${error.message}`, 'ERROR'); } if (chunkIndex < chunks.length - 1) { await sleep(1500); } } return results; } // ============= HELPER FUNCTIONS ============= /** * Sleep function replacement for Utilities.sleep */ // FONCTION SUPPRIMÉE : sleep() dupliquée - déjà définie ligne 12 /** * RESTAURÉ DEPUIS .GS : Génération des paires FAQ cohérentes */ async function generateFAQPairsRestored(faqPairs, csvData, aiProvider) { logSh(`🔍 === GÉNÉRATION PAIRES FAQ (logique .gs restaurée) ===`, 'INFO'); if (faqPairs.length === 0) return {}; const batchPrompt = createBatchFAQPairsPrompt(faqPairs, csvData); logSh(`🔍 Prompt FAQ paires (${batchPrompt.length} chars): "${batchPrompt.substring(0, 300)}..."`, 'DEBUG'); try { const batchResponse = await callLLM(aiProvider, batchPrompt, { temperature: 0.8, maxTokens: 3000 // Plus large pour les paires }, csvData.personality); logSh(`🔍 Réponse FAQ paires reçue: ${batchResponse.length} caractères`, 'DEBUG'); logSh(`🔍 Début réponse: "${batchResponse.substring(0, 200)}..."`, 'DEBUG'); return parseFAQPairsResponse(batchResponse, faqPairs); } catch (error) { logSh(`❌ FATAL: Erreur génération paires FAQ: ${error.message}`, 'ERROR'); throw new Error(`FATAL: Génération paires FAQ échouée - arrêt du workflow: ${error.message}`); } } /** * RESTAURÉ DEPUIS .GS : Prompt pour paires FAQ cohérentes */ function createBatchFAQPairsPrompt(faqPairs, csvData) { const personality = csvData.personality; let prompt = `=== 1. CONTEXTE === Entreprise: Autocollant.fr - signalétique personnalisée Sujet: ${csvData.mc0} Section: FAQ pour article SEO commercial === 2. PERSONNALITÉ === Rédacteur: ${personality.nom} Style: ${personality.style} Ton: ${personality.description || 'professionnel'} === 3. RÈGLES GÉNÉRALES === - Questions naturelles de clients - Réponses expertes et rassurantes - Langage professionnel mais accessible - Textes rédigés humainement et de façon authentique - Couvrir: prix, livraison, personnalisation, installation, durabilité - IMPÉRATIF: Respecter strictement les contraintes XML === 4. PAIRES FAQ À GÉNÉRER === `; faqPairs.forEach((pair, index) => { const questionTag = pair.question.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, ''); const answerTag = pair.answer.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, ''); prompt += `${index + 1}. [${questionTag}] + [${answerTag}] - Paire FAQ naturelle `; }); prompt += ` FORMAT DE RÉPONSE: PAIRE 1: [${faqPairs[0].question.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '')}] Question client directe et naturelle sur ${csvData.mc0} ? [${faqPairs[0].answer.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '')}] Réponse utile et rassurante selon le style ${personality.style} de ${personality.nom}. `; if (faqPairs.length > 1) { prompt += `PAIRE 2: etc... `; } return prompt; } /** * RESTAURÉ DEPUIS .GS : Parser réponse paires FAQ */ function parseFAQPairsResponse(response, faqPairs) { const results = {}; logSh(`🔍 Parsing FAQ paires: "${response.substring(0, 300)}..."`, 'DEBUG'); // Parser avec regex [TAG] contenu const regex = /\[([^\]]+)\]\s*([^[]*?)(?=\[|$)/gs; let match; const parsedItems = {}; while ((match = regex.exec(response)) !== null) { const tag = match[1].trim(); let content = match[2].trim().replace(/\n\s*\n/g, '\n').replace(/^\n+|\n+$/g, ''); // NOUVEAU: Appliquer le nettoyage XML pour FAQ aussi content = cleanXMLTagsFromContent(content); if (content && content.length > 0) { parsedItems[tag] = content; logSh(`🔍 Parsé [${tag}]: "${content.substring(0, 100)}..."`, 'DEBUG'); } } // Mapper aux vrais tags FAQ avec | let pairesCompletes = 0; faqPairs.forEach(pair => { const questionCleanTag = pair.question.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, ''); const answerCleanTag = pair.answer.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, ''); const questionContent = parsedItems[questionCleanTag]; const answerContent = parsedItems[answerCleanTag]; if (questionContent && answerContent) { results[pair.question.tag] = questionContent; results[pair.answer.tag] = answerContent; pairesCompletes++; logSh(`✅ Paire FAQ ${pair.number} complète: Q="${questionContent}" R="${answerContent.substring(0, 50)}..."`, 'INFO'); } else { logSh(`⚠️ Paire FAQ ${pair.number} incomplète: Q=${!!questionContent} R=${!!answerContent}`, 'WARNING'); if (questionContent) results[pair.question.tag] = questionContent; if (answerContent) results[pair.answer.tag] = answerContent; } }); logSh(`📊 FAQ parsing: ${pairesCompletes}/${faqPairs.length} paires complètes`, 'INFO'); // FATAL si aucune paire complète (comme dans le .gs) if (pairesCompletes === 0 && faqPairs.length > 0) { logSh(`❌ FATAL: Aucune paire FAQ générée correctement`, 'ERROR'); throw new Error(`FATAL: Génération FAQ incomplète (0/${faqPairs.length} paires complètes) - arrêt du workflow`); } return results; } /** * RESTAURÉ DEPUIS .GS : Nettoyer instructions FAQ */ function cleanFAQInstructions(instructions, csvData) { if (!instructions) return ''; let cleanInstructions = instructions; // Remplacer variables cleanInstructions = cleanInstructions.replace(/\{\{T0\}\}/g, csvData.t0 || ''); cleanInstructions = cleanInstructions.replace(/\{\{MC0\}\}/g, csvData.mc0 || ''); cleanInstructions = cleanInstructions.replace(/\{\{T-1\}\}/g, csvData.tMinus1 || ''); cleanInstructions = cleanInstructions.replace(/\{\{L-1\}\}/g, csvData.lMinus1 || ''); // Variables multiples MC+1_X, T+1_X, L+1_X if (csvData.mcPlus1) { const mcPlus1 = csvData.mcPlus1.split(',').map(s => s.trim()); for (let i = 1; i <= 6; i++) { const mcValue = mcPlus1[i-1] || `[MC+1_${i} non défini]`; cleanInstructions = cleanInstructions.replace(new RegExp(`\\{\\{MC\\+1_${i}\\}\\}`, 'g'), mcValue); } } if (csvData.tPlus1) { const tPlus1 = csvData.tPlus1.split(',').map(s => s.trim()); for (let i = 1; i <= 6; i++) { const tValue = tPlus1[i-1] || `[T+1_${i} non défini]`; cleanInstructions = cleanInstructions.replace(new RegExp(`\\{\\{T\\+1_${i}\\}\\}`, 'g'), tValue); } } // Nettoyer HTML cleanInstructions = cleanInstructions.replace(/<\/?[^>]+>/g, ''); cleanInstructions = cleanInstructions.replace(/\s+/g, ' ').trim(); return cleanInstructions; } /** * Collecter tous les éléments dans l'ordre XML original * CORRECTION: Suit l'ordre séquentiel XML au lieu de grouper par section */ function collectAllElements(hierarchy) { const allElements = []; const tagToElementMap = {}; // 1. Créer un mapping de tous les éléments disponibles Object.keys(hierarchy).forEach(path => { const section = hierarchy[path]; if (section.title) { tagToElementMap[section.title.originalElement.originalTag] = { tag: section.title.originalElement.originalTag, element: section.title.originalElement, type: 'titre' }; } if (section.text) { tagToElementMap[section.text.originalElement.originalTag] = { tag: section.text.originalElement.originalTag, element: section.text.originalElement, type: 'texte' }; } section.questions.forEach(q => { tagToElementMap[q.originalElement.originalTag] = { tag: q.originalElement.originalTag, element: q.originalElement, type: q.originalElement.type }; }); }); // 2. Récupérer l'ordre XML original depuis le template global logSh(`🔍 Global XML Template disponible: ${!!global.currentXmlTemplate}`, 'DEBUG'); if (global.currentXmlTemplate && global.currentXmlTemplate.length > 0) { logSh(`🔍 Template XML: ${global.currentXmlTemplate.substring(0, 200)}...`, 'DEBUG'); const regex = /\|([^|]+)\|/g; let match; // Parcourir le XML dans l'ordre d'apparition while ((match = regex.exec(global.currentXmlTemplate)) !== null) { const fullMatch = match[1]; // Extraire le nom du tag (sans variables) const nameMatch = fullMatch.match(/^([^{]+)/); const tagName = nameMatch ? nameMatch[1].trim() : fullMatch.split('{')[0]; const pureTag = `|${tagName}|`; // Si cet élément existe dans notre mapping, l'ajouter dans l'ordre if (tagToElementMap[pureTag]) { allElements.push(tagToElementMap[pureTag]); logSh(`🔍 Ajouté dans l'ordre: ${pureTag}`, 'DEBUG'); delete tagToElementMap[pureTag]; // Éviter les doublons } else { logSh(`🔍 Tag XML non trouvé dans mapping: ${pureTag}`, 'DEBUG'); } } } // 3. Ajouter les éléments restants (sécurité) const remainingElements = Object.values(tagToElementMap); if (remainingElements.length > 0) { logSh(`🔍 Éléments restants ajoutés: ${remainingElements.map(el => el.tag).join(', ')}`, 'DEBUG'); remainingElements.forEach(element => { allElements.push(element); }); } logSh(`🔍 ORDRE FINAL: ${allElements.map(el => el.tag.replace(/\|/g, '')).join(' → ')}`, 'INFO'); return allElements; } /** * RESTAURÉ DEPUIS .GS : Séparer les paires FAQ des autres éléments */ function separateFAQPairsAndOthers(allElements) { const faqPairs = []; const otherElements = []; const faqQuestions = {}; const faqAnswers = {}; // 1. Collecter toutes les questions et réponses FAQ allElements.forEach(element => { if (element.type === 'faq_question') { // Extraire le numéro : |Faq_q_1| → 1 const numberMatch = element.tag.match(/(\d+)/); const faqNumber = numberMatch ? numberMatch[1] : '1'; faqQuestions[faqNumber] = element; logSh(`🔍 Question FAQ ${faqNumber} trouvée: ${element.tag}`, 'DEBUG'); } else if (element.type === 'faq_reponse') { // Extraire le numéro : |Faq_a_1| → 1 const numberMatch = element.tag.match(/(\d+)/); const faqNumber = numberMatch ? numberMatch[1] : '1'; faqAnswers[faqNumber] = element; logSh(`🔍 Réponse FAQ ${faqNumber} trouvée: ${element.tag}`, 'DEBUG'); } else { // Élément normal (titre, texte, intro, etc.) otherElements.push(element); } }); // 2. Créer les paires FAQ cohérentes Object.keys(faqQuestions).forEach(number => { const question = faqQuestions[number]; const answer = faqAnswers[number]; if (question && answer) { faqPairs.push({ number: number, question: question, answer: answer }); logSh(`✅ Paire FAQ ${number} créée: ${question.tag} + ${answer.tag}`, 'INFO'); } else if (question) { logSh(`⚠️ Question FAQ ${number} sans réponse correspondante`, 'WARNING'); otherElements.push(question); // Traiter comme élément individuel } else if (answer) { logSh(`⚠️ Réponse FAQ ${number} sans question correspondante`, 'WARNING'); otherElements.push(answer); // Traiter comme élément individuel } }); logSh(`🔍 Séparation terminée: ${faqPairs.length} paires FAQ, ${otherElements.length} autres éléments`, 'INFO'); return { faqPairs, otherElements }; } /** * Grouper éléments par type */ function groupElementsByType(elements) { const groups = {}; elements.forEach(element => { const type = element.type; if (!groups[type]) { groups[type] = []; } groups[type].push(element); }); return groups; } /** * Diviser array en chunks */ function chunkArray(array, size) { const chunks = []; for (let i = 0; i < array.length; i += size) { chunks.push(array.slice(i, i + size)); } return chunks; } /** * Trouver le titre associé à un élément texte */ function findAssociatedTitle(textElement, existingResults) { const textName = textElement.element.name || textElement.tag; // STRATÉGIE 1: Correspondance directe (Txt_H2_1 → Titre_H2_1) const directMatch = textName.replace(/Txt_/, 'Titre_').replace(/Text_/, 'Titre_'); const directTitle = existingResults[`|${directMatch}|`] || existingResults[directMatch]; if (directTitle) return directTitle; // STRATÉGIE 2: Même niveau hiérarchique (H2, H3) const levelMatch = textName.match(/(H\d)_(\d+)/); if (levelMatch) { const [, level, number] = levelMatch; const titleTag = `Titre_${level}_${number}`; const levelTitle = existingResults[`|${titleTag}|`] || existingResults[titleTag]; if (levelTitle) return levelTitle; } // STRATÉGIE 3: Proximité dans l'ordre (texte suivant un titre) const allTitles = Object.entries(existingResults) .filter(([tag]) => tag.includes('Titre')) .sort(([a], [b]) => a.localeCompare(b)); if (allTitles.length > 0) { // Retourner le premier titre disponible comme contexte général return allTitles[0][1]; } return null; } /** * Créer prompt batch de base */ function createBatchBasePrompt(elements, type, csvData, existingResults = {}) { const personality = csvData.personality; let prompt = `=== 1. CONTEXTE === Entreprise: Autocollant.fr - signalétique personnalisée Sujet: ${csvData.mc0} Type d'article: SEO professionnel pour site commercial === 2. PERSONNALITÉ === Rédacteur: ${personality.nom} Style: ${personality.style} Ton: ${personality.description || 'professionnel'} === 3. RÈGLES GÉNÉRALES === - Contenu SEO optimisé - Langage naturel et fluide - Éviter répétitions - Pas de références techniques dans le contenu - Textes rédigés humainement et de façon authentique - IMPÉRATIF: Respecter strictement les contraintes XML (nombre de mots, etc.) === 4. ÉLÉMENTS À GÉNÉRER === `; // AJOUTER CONTEXTE DES TITRES POUR LES TEXTES if (type === 'texte' && Object.keys(existingResults).length > 0) { const generatedTitles = Object.entries(existingResults) .filter(([tag]) => tag.includes('Titre')) .map(([tag, title]) => `• ${tag.replace(/\|/g, '')}: "${title}"`) .slice(0, 5); // Limiter à 5 titres pour éviter surcharge if (generatedTitles.length > 0) { prompt += ` Titres existants pour contexte: ${generatedTitles.join('\n')} `; } } elements.forEach((elementInfo, index) => { const cleanTag = elementInfo.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, ''); prompt += `${index + 1}. [${cleanTag}] `; // INSTRUCTIONS PROPRES PAR ÉLÉMENT if (type === 'titre') { if (elementInfo.element.type === 'titre_h1') { prompt += `Titre principal accrocheur\n`; } else if (elementInfo.element.type === 'titre_h2') { prompt += `Titre de section engageant\n`; } else if (elementInfo.element.type === 'titre_h3') { prompt += `Sous-titre spécialisé\n`; } else { prompt += `Titre pertinent\n`; } } else if (type === 'texte') { prompt += `Paragraphe informatif\n`; // ASSOCIER LE TITRE CORRESPONDANT AUTOMATIQUEMENT const associatedTitle = findAssociatedTitle(elementInfo, existingResults); if (associatedTitle) { prompt += ` Contexte: "${associatedTitle}"\n`; } if (elementInfo.element.resolvedContent) { prompt += ` Angle: "${elementInfo.element.resolvedContent}"\n`; } } else if (type === 'intro') { prompt += `Introduction engageante\n`; } else { prompt += `Contenu pertinent\n`; } }); prompt += `\nSTYLE ${personality.nom.toUpperCase()} - ${personality.style}: - Vocabulaire: ${personality.vocabulairePref} - Connecteurs: ${personality.connecteursPref} - Phrases: ${personality.longueurPhrases} - Niveau technique: ${personality.niveauTechnique} CONSIGNES STRICTES POUR ARTICLE SEO: - CONTEXTE: Article professionnel pour site e-commerce, destiné aux clients potentiels - STYLE: ${personality.style} de ${personality.nom} mais ADAPTÉ au web professionnel - INTERDICTION ABSOLUE: expressions trop familières répétées ("du coup", "bon", "franchement", "nickel", "tip-top") - VOCABULAIRE: Mélange expertise technique + accessibilité client - SEO: Utilise naturellement "${csvData.mc0}" et termes associés - POUR LES TITRES: Titre SEO attractif UNIQUEMENT, JAMAIS "Titre_H1_1" ou "Titre_H2_7" - EXEMPLE TITRE: "Plaques personnalisées résistantes : guide complet 2024" - CONTENU: Informatif, rassurant, incite à l'achat SANS être trop commercial - RÉPONDS DIRECTEMENT par le contenu web demandé, SANS préfixe FORMAT DE RÉPONSE ${type === 'titre' ? '(TITRES UNIQUEMENT)' : ''}: [${elements[0].tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '')}] ${type === 'titre' ? 'Titre réel et attractif (PAS "Titre_H1_1")' : 'Contenu rédigé selon le style ' + personality.nom} [${elements[1] ? elements[1].tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '') : 'element2'}] ${type === 'titre' ? 'Titre réel et attractif (PAS "Titre_H2_1")' : 'Contenu rédigé selon le style ' + personality.nom} etc...`; return prompt; } /** * Parser réponse batch générique avec nettoyage des tags XML */ function parseBatchResponse(response, elements) { const results = {}; // Parser avec regex [TAG] contenu const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs; let match; const parsedItems = {}; while ((match = regex.exec(response)) !== null) { const tag = match[1].trim(); let content = match[2].trim(); // NOUVEAU: Nettoyer les tags XML qui peuvent apparaître dans le contenu content = cleanXMLTagsFromContent(content); parsedItems[tag] = content; } // Mapper aux vrais tags avec | elements.forEach(element => { const cleanTag = element.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, ''); if (parsedItems[cleanTag] && parsedItems[cleanTag].length > 10) { results[element.tag] = parsedItems[cleanTag]; logSh(`✅ Parsé [${cleanTag}]: "${parsedItems[cleanTag].substring(0, 100)}..."`, 'DEBUG'); } else { // Fallback si parsing échoue ou contenu trop court results[element.tag] = `Contenu professionnel pour ${element.element.name}`; logSh(`⚠️ Fallback [${cleanTag}]: parsing échoué ou contenu invalide`, 'WARNING'); } }); return results; } /** * NOUVELLE FONCTION: Nettoyer les tags XML du contenu généré */ function cleanXMLTagsFromContent(content) { if (!content) return content; // Supprimer les tags XML avec ** content = content.replace(/\*\*[^*]+\*\*/g, ''); // Supprimer les préfixes de titres indésirables content = content.replace(/^(Bon,?\s*)?(alors,?\s*)?(voici\s+le\s+topo\s+pour\s+)?Titre_[HU]\d+_\d+[.,\s]*/gi, ''); content = content.replace(/^(Bon,?\s*)?(alors,?\s*)?pour\s+Titre_[HU]\d+_\d+[.,\s]*/gi, ''); content = content.replace(/^(Bon,?\s*)?(donc,?\s*)?Titre_[HU]\d+_\d+[.,\s]*/gi, ''); // Supprimer les messages d'excuse content = content.replace(/Oh là là,?\s*je\s*(suis\s*)?(\w+\s*)?désolée?\s*,?\s*mais\s*je\s*n'ai\s*pas\s*l'information.*?(?=\.|$)/gi, ''); content = content.replace(/Bon,?\s*passons\s*au\s*suivant.*?(?=\.|$)/gi, ''); content = content.replace(/je\s*ne\s*sais\s*pas\s*quoi\s*vous\s*dire.*?(?=\.|$)/gi, ''); content = content.replace(/encore\s*un\s*point\s*où\s*je\s*n'ai\s*pas\s*l'information.*?(?=\.|$)/gi, ''); // Réduire les répétitions excessives d'expressions familières content = content.replace(/(du coup[,\s]+){3,}/gi, 'du coup '); content = content.replace(/(bon[,\s]+){3,}/gi, 'bon '); content = content.replace(/(franchement[,\s]+){3,}/gi, 'franchement '); content = content.replace(/(alors[,\s]+){3,}/gi, 'alors '); content = content.replace(/(nickel[,\s]+){2,}/gi, 'nickel '); content = content.replace(/(tip-top[,\s]+){2,}/gi, 'tip-top '); content = content.replace(/(costaud[,\s]+){2,}/gi, 'costaud '); // Nettoyer espaces multiples et retours ligne content = content.replace(/\s{2,}/g, ' '); content = content.replace(/\n{2,}/g, '\n'); content = content.trim(); return content; } // ============= PARSING FUNCTIONS ============= // FONCTION SUPPRIMÉE : parseAllTechnicalTermsResponse() - Parser batch défaillant remplacé par traitement individuel // FONCTIONS SUPPRIMÉES : parseTechnicalEnhancementBatchResponse() et parseTechnicalBatchResponse() - Remplacées par traitement individuel // Placeholder pour les fonctions de parsing conservées qui suivent function parseTransitionsBatchResponse(response, chunk) { const results = {}; const regex = /\[(\d+)\]\s*([^[]*?)(?=\n\[\d+\]|$)/gs; let match; let index = 0; while ((match = regex.exec(response)) && index < chunk.length) { let content = match[2].trim(); // Appliquer le nettoyage XML content = cleanXMLTagsFromContent(content); if (content && content.length > 10) { results[chunk[index].tag] = content; } else { // Fallback si contenu invalide results[chunk[index].tag] = chunk[index].content; // Garder contenu original } index++; } return results; } function parseStyleBatchResponse(response, chunk) { const results = {}; const regex = /\[(\d+)\]\s*([^[]*?)(?=\n\[\d+\]|$)/gs; let match; let index = 0; while ((match = regex.exec(response)) && index < chunk.length) { let content = match[2].trim(); // Appliquer le nettoyage XML content = cleanXMLTagsFromContent(content); if (content && content.length > 10) { results[chunk[index].tag] = content; } else { // Fallback si contenu invalide results[chunk[index].tag] = chunk[index].content; // Garder contenu original } index++; } return results; } // ============= ANALYSIS FUNCTIONS ============= /** * Analyser besoin d'amélioration transitions */ function analyzeTransitionNeed(content) { const sentences = content.split(/[.!?]+/).filter(s => s.trim().length > 10); // Critères multiples d'analyse const metrics = { repetitiveConnectors: analyzeRepetitiveConnectors(content), abruptTransitions: analyzeAbruptTransitions(sentences), sentenceVariety: analyzeSentenceVariety(sentences), formalityLevel: analyzeFormalityLevel(content), overallLength: content.length }; // Score de besoin (0-1) let needScore = 0; needScore += metrics.repetitiveConnectors * 0.3; needScore += metrics.abruptTransitions * 0.4; needScore += (1 - metrics.sentenceVariety) * 0.2; needScore += metrics.formalityLevel * 0.1; // Seuil ajustable selon longueur const threshold = metrics.overallLength > 300 ? 0.4 : 0.6; logSh(`🔍 Analyse transitions: score=${needScore.toFixed(2)}, seuil=${threshold}`, 'DEBUG'); return needScore > threshold; } function analyzeRepetitiveConnectors(content) { const connectors = ['par ailleurs', 'en effet', 'de plus', 'cependant', 'ainsi', 'donc']; let totalConnectors = 0; let repetitions = 0; connectors.forEach(connector => { const matches = (content.match(new RegExp(`\\b${connector}\\b`, 'gi')) || []); totalConnectors += matches.length; if (matches.length > 1) repetitions += matches.length - 1; }); return totalConnectors > 0 ? repetitions / totalConnectors : 0; } function analyzeAbruptTransitions(sentences) { if (sentences.length < 2) return 0; let abruptCount = 0; for (let i = 1; i < sentences.length; i++) { const current = sentences[i].trim(); const previous = sentences[i-1].trim(); const hasConnector = hasTransitionWord(current); const topicContinuity = calculateTopicContinuity(previous, current); // Transition abrupte = pas de connecteur + faible continuité thématique if (!hasConnector && topicContinuity < 0.3) { abruptCount++; } } return abruptCount / (sentences.length - 1); } function analyzeSentenceVariety(sentences) { if (sentences.length < 2) return 1; const lengths = sentences.map(s => s.trim().length); const avgLength = lengths.reduce((a, b) => a + b, 0) / lengths.length; // Calculer variance des longueurs const variance = lengths.reduce((acc, len) => acc + Math.pow(len - avgLength, 2), 0) / lengths.length; const stdDev = Math.sqrt(variance); // Score de variété (0-1) - plus la variance est élevée, plus c'est varié return Math.min(1, stdDev / avgLength); } function analyzeFormalityLevel(content) { const formalIndicators = [ 'il convient de', 'par conséquent', 'néanmoins', 'toutefois', 'de surcroît', 'en définitive', 'il s\'avère que', 'force est de constater' ]; let formalCount = 0; formalIndicators.forEach(indicator => { if (content.toLowerCase().includes(indicator)) formalCount++; }); const sentences = content.split(/[.!?]+/).length; return sentences > 0 ? formalCount / sentences : 0; } function calculateTopicContinuity(sentence1, sentence2) { const stopWords = ['les', 'des', 'une', 'sont', 'avec', 'pour', 'dans', 'cette', 'vous', 'peut', 'tout']; const words1 = extractSignificantWords(sentence1, stopWords); const words2 = extractSignificantWords(sentence2, stopWords); if (words1.length === 0 || words2.length === 0) return 0; const commonWords = words1.filter(word => words2.includes(word)); const semanticSimilarity = commonWords.length / Math.min(words1.length, words2.length); const technicalWords = ['plaque', 'dibond', 'aluminium', 'impression', 'signalétique']; const commonTechnical = commonWords.filter(word => technicalWords.includes(word)); const technicalBonus = commonTechnical.length * 0.2; return Math.min(1, semanticSimilarity + technicalBonus); } function extractSignificantWords(sentence, stopWords) { return sentence.toLowerCase() .match(/\b[a-zàâäéèêëïîôùûüÿç]{4,}\b/g) // Mots 4+ lettres avec accents ?.filter(word => !stopWords.includes(word)) || []; } function hasTransitionWord(sentence) { const connectors = ['par ailleurs', 'en effet', 'de plus', 'cependant', 'ainsi', 'donc', 'ensuite', 'puis', 'également', 'aussi', 'toutefois', 'néanmoins', 'alors', 'enfin']; return connectors.some(connector => sentence.toLowerCase().includes(connector)); } /** * Instructions de style dynamiques */ function getPersonalityStyleInstructions(personality) { // CORRECTION: Utilisation des VRAIS champs Google Sheets au lieu du hardcodé if (!personality) return "Style professionnel standard"; const instructions = `STYLE ${personality.nom.toUpperCase()} (${personality.style}): - Description: ${personality.description} - Vocabulaire préféré: ${personality.vocabulairePref || 'professionnel, qualité'} - Connecteurs préférés: ${personality.connecteursPref || 'par ailleurs, en effet'} - Mots-clés secteurs: ${personality.motsClesSecteurs || 'technique, qualité'} - Longueur phrases: ${personality.longueurPhrases || 'Moyennes (15-25 mots)'} - Niveau technique: ${personality.niveauTechnique || 'Accessible'} - Style CTA: ${personality.ctaStyle || 'Professionnel'} - Défauts simulés: ${personality.defautsSimules || 'Aucun'} - Erreurs typiques à éviter: ${personality.erreursTypiques || 'Répétitions, généralités'}`; return instructions; } /** * Créer prompt pour élément (fonction de base nécessaire) */ function createPromptForElement(element, csvData) { const personality = csvData.personality; const styleContext = `Rédige dans le style ${personality.style} de ${personality.nom} (${personality.description}).`; switch (element.type) { case 'titre_h1': return `${styleContext} MISSION: Crée un titre H1 accrocheur pour: ${csvData.mc0} Référence: ${csvData.t0} CONSIGNES: 10 mots maximum, direct et impactant, optimisé SEO. RÉPONDS UNIQUEMENT PAR LE TITRE, sans introduction.`; case 'titre_h2': return `${styleContext} MISSION: Crée un titre H2 optimisé SEO pour: ${csvData.mc0} CONSIGNES: Intègre naturellement le mot-clé, 8 mots maximum. RÉPONDS UNIQUEMENT PAR LE TITRE, sans introduction.`; case 'intro': if (element.instructions) { return `${styleContext} MISSION: ${element.instructions} Données contextuelles: - MC0: ${csvData.mc0} - T-1: ${csvData.tMinus1} - L-1: ${csvData.lMinus1} RÉPONDS UNIQUEMENT PAR LE CONTENU, sans présentation.`; } return `${styleContext} MISSION: Rédige une introduction de 100 mots pour ${csvData.mc0}. RÉPONDS UNIQUEMENT PAR LE CONTENU, sans présentation.`; case 'texte': if (element.instructions) { return `${styleContext} MISSION: ${element.instructions} RÉPONDS UNIQUEMENT PAR LE CONTENU, sans présentation.`; } return `${styleContext} MISSION: Rédige un paragraphe de 150 mots sur ${csvData.mc0}. RÉPONDS UNIQUEMENT PAR LE CONTENU, sans présentation.`; case 'faq_question': if (element.instructions) { return `${styleContext} MISSION: ${element.instructions} CONTEXTE: ${csvData.mc0} - ${csvData.t0} STYLE: Question ${csvData.personality?.style} de ${csvData.personality?.nom} CONSIGNES: - Vraie question que se poserait un client intéressé par ${csvData.mc0} - Commence par "Comment", "Quel", "Pourquoi", "Où", "Quand" ou "Est-ce que" - Maximum 15 mots, pratique et concrète - Vocabulaire: ${csvData.personality?.vocabulairePref || 'accessible'} RÉPONDS UNIQUEMENT PAR LA QUESTION, sans guillemets ni introduction.`; } return `${styleContext} MISSION: Génère une vraie question FAQ client sur ${csvData.mc0}. CONSIGNES: - Question pratique et concrète qu'un client se poserait - Commence par "Comment", "Quel", "Pourquoi", "Combien", "Où" ou "Est-ce que" - Maximum 15 mots, style ${csvData.personality?.style} - Vocabulaire: ${csvData.personality?.vocabulairePref || 'accessible'} RÉPONDS UNIQUEMENT PAR LA QUESTION, sans guillemets ni introduction.`; case 'faq_reponse': if (element.instructions) { return `${styleContext} MISSION: ${element.instructions} CONTEXTE: ${csvData.mc0} - ${csvData.t0} STYLE: Réponse ${csvData.personality?.style} de ${csvData.personality?.nom} CONSIGNES: - Réponse utile et rassurante - 50-80 mots, ton ${csvData.personality?.style} - Vocabulaire: ${csvData.personality?.vocabulairePref} - Connecteurs: ${csvData.personality?.connecteursPref} RÉPONDS UNIQUEMENT PAR LA RÉPONSE, sans introduction.`; } return `${styleContext} MISSION: Réponds à une question client sur ${csvData.mc0}. CONSIGNES: - Réponse utile, claire et rassurante - 50-80 mots, ton ${csvData.personality?.style} de ${csvData.personality?.nom} - Vocabulaire: ${csvData.personality?.vocabulairePref || 'professionnel'} - Connecteurs: ${csvData.personality?.connecteursPref || 'par ailleurs'} RÉPONDS UNIQUEMENT PAR LA RÉPONSE, sans introduction.`; default: return `${styleContext} MISSION: Génère du contenu pertinent pour ${csvData.mc0}. RÉPONDS UNIQUEMENT PAR LE CONTENU, sans présentation.`; } } /** * NOUVELLE FONCTION : Extraction batch TOUS les termes techniques */ async function extractAllTechnicalTermsBatch(baseContents, csvData, aiProvider) { const contentEntries = Object.keys(baseContents); const batchAnalysisPrompt = `MISSION: Analyser ces ${contentEntries.length} contenus et identifier leurs termes techniques. CONTEXTE: ${csvData.mc0} - Secteur: signalétique/impression CONTENUS À ANALYSER: ${contentEntries.map((tag, i) => `[${i + 1}] TAG: ${tag} CONTENU: "${baseContents[tag]}"`).join('\n\n')} CONSIGNES: - Identifie UNIQUEMENT les vrais termes techniques métier/industrie - Évite mots génériques (qualité, service, pratique, personnalisé, etc.) - Focus: matériaux, procédés, normes, dimensions, technologies - Si aucun terme technique → "AUCUN" EXEMPLES VALIDES: dibond, impression UV, fraisage CNC, épaisseur 3mm, aluminium brossé EXEMPLES INVALIDES: durable, pratique, personnalisé, moderne, esthétique FORMAT RÉPONSE EXACT: [1] dibond, impression UV, 3mm OU AUCUN [2] aluminium, fraisage CNC OU AUCUN [3] AUCUN etc... (${contentEntries.length} lignes total)`; try { const analysisResponse = await callLLM(aiProvider, batchAnalysisPrompt, { temperature: 0.3, maxTokens: 2000 }, csvData.personality); return parseAllTechnicalTermsResponse(analysisResponse, baseContents, contentEntries); } catch (error) { logSh(`❌ FATAL: Extraction termes techniques batch échouée: ${error.message}`, 'ERROR'); throw new Error(`FATAL: Analyse termes techniques impossible - arrêt du workflow: ${error.message}`); } } /** * NOUVELLE FONCTION : Enhancement batch TOUS les éléments */ async function enhanceAllElementsTechnicalBatch(elementsNeedingEnhancement, csvData, aiProvider) { if (elementsNeedingEnhancement.length === 0) return {}; const batchEnhancementPrompt = `MISSION: Améliore UNIQUEMENT la précision technique de ces ${elementsNeedingEnhancement.length} contenus. CONTEXTE: Article SEO pour site e-commerce de signalétique PERSONNALITÉ: ${csvData.personality?.nom} (${csvData.personality?.style} web professionnel) SUJET: ${csvData.mc0} - Secteur: Signalétique/impression VOCABULAIRE PRÉFÉRÉ: ${csvData.personality?.vocabulairePref} CONTENUS + TERMES À AMÉLIORER: ${elementsNeedingEnhancement.map((item, i) => `[${i + 1}] TAG: ${item.tag} CONTENU ACTUEL: "${item.content}" TERMES TECHNIQUES À INTÉGRER: ${item.technicalTerms.join(', ')}`).join('\n\n')} CONSIGNES STRICTES: - Améliore UNIQUEMENT la précision technique, garde le style ${csvData.personality?.nom} - GARDE la même longueur, structure et ton - Intègre naturellement les termes techniques listés - NE CHANGE PAS le fond du message ni le style personnel - Utilise un vocabulaire expert mais accessible - ÉVITE les répétitions excessives - RESPECTE le niveau technique: ${csvData.personality?.niveauTechnique} - Termes techniques secteur: dibond, aluminium, impression UV, fraisage, épaisseur, PMMA FORMAT RÉPONSE: [1] Contenu avec amélioration technique selon ${csvData.personality?.nom} [2] Contenu avec amélioration technique selon ${csvData.personality?.nom} etc... (${elementsNeedingEnhancement.length} éléments total)`; try { const enhanced = await callLLM(aiProvider, batchEnhancementPrompt, { temperature: 0.4, maxTokens: 5000 // Plus large pour batch total }, csvData.personality); return parseTechnicalEnhancementBatchResponse(enhanced, elementsNeedingEnhancement); } catch (error) { logSh(`❌ FATAL: Enhancement technique batch échoué: ${error.message}`, 'ERROR'); throw new Error(`FATAL: Enhancement technique batch impossible - arrêt du workflow: ${error.message}`); } } /** * Parser réponse extraction termes */ function parseAllTechnicalTermsResponse(response, baseContents, contentEntries) { const results = []; const regex = /\[(\d+)\]\s*([^[]*?)(?=\[\d+\]|$)/gs; let match; const parsedItems = {}; // Parser la réponse while ((match = regex.exec(response)) !== null) { const index = parseInt(match[1]) - 1; // Convertir en 0-indexé const termsText = match[2].trim(); parsedItems[index] = termsText; } // Mapper aux éléments contentEntries.forEach((tag, index) => { const termsText = parsedItems[index] || 'AUCUN'; const hasTerms = !termsText.toUpperCase().includes('AUCUN'); const technicalTerms = hasTerms ? termsText.split(',').map(t => t.trim()).filter(t => t.length > 0) : []; results.push({ tag: tag, content: baseContents[tag], technicalTerms: technicalTerms, needsEnhancement: hasTerms && technicalTerms.length > 0 }); logSh(`🔍 [${tag}]: ${hasTerms ? technicalTerms.join(', ') : 'pas de termes techniques'}`, 'DEBUG'); }); const enhancementCount = results.filter(r => r.needsEnhancement).length; logSh(`📊 Analyse terminée: ${enhancementCount}/${contentEntries.length} éléments ont besoin d'enhancement`, 'INFO'); return results; } /** * Parser réponse enhancement technique */ function parseTechnicalEnhancementBatchResponse(response, elementsNeedingEnhancement) { const results = {}; const regex = /\[(\d+)\]\s*([^[]*?)(?=\[\d+\]|$)/gs; let match; let index = 0; while ((match = regex.exec(response)) && index < elementsNeedingEnhancement.length) { let content = match[2].trim(); const element = elementsNeedingEnhancement[index]; // NOUVEAU: Appliquer le nettoyage XML content = cleanXMLTagsFromContent(content); if (content && content.length > 10) { results[element.tag] = content; logSh(`✅ Enhanced [${element.tag}]: "${content.substring(0, 100)}..."`, 'DEBUG'); } else { // Fallback si contenu invalide après nettoyage results[element.tag] = element.content; logSh(`⚠️ Fallback [${element.tag}]: contenu invalide après nettoyage`, 'WARNING'); } index++; } // Vérifier si on a bien tout parsé if (Object.keys(results).length < elementsNeedingEnhancement.length) { logSh(`⚠️ Parsing partiel: ${Object.keys(results).length}/${elementsNeedingEnhancement.length}`, 'WARNING'); // Compléter avec contenu original pour les manquants elementsNeedingEnhancement.forEach(element => { if (!results[element.tag]) { results[element.tag] = element.content; } }); } return results; } // ============= EXPORTS ============= module.exports = { generateWithBatchEnhancement, generateAllContentBase, enhanceAllTechnicalTerms, enhanceAllTransitions, enhanceAllPersonalityStyle, collectAllElements, groupElementsByType, chunkArray, createBatchBasePrompt, parseBatchResponse, cleanXMLTagsFromContent, analyzeTransitionNeed, getPersonalityStyleInstructions, createPromptForElement, sleep, separateFAQPairsAndOthers, generateFAQPairsRestored, createBatchFAQPairsPrompt, parseFAQPairsResponse, cleanFAQInstructions, extractAllTechnicalTermsBatch, enhanceAllElementsTechnicalBatch, parseAllTechnicalTermsResponse, parseTechnicalEnhancementBatchResponse }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/ContentGeneration.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: lib/content-generation.js - CONVERTI POUR NODE.JS // Description: Génération de contenu avec batch enhancement // ======================================== // 🔄 NODE.JS IMPORTS const { logSh } = require('./ErrorReporting'); const { generateWithBatchEnhancement } = require('./SelectiveEnhancement'); // ============= GÉNÉRATION PRINCIPALE - ADAPTÉE ============= async function generateWithContext(hierarchy, csvData) { logSh('=== GÉNÉRATION AVEC BATCH ENHANCEMENT ===', 'INFO'); // *** UTILISE LE SELECTIVE ENHANCEMENT *** return await generateWithBatchEnhancement(hierarchy, csvData); } // ============= EXPORTS ============= module.exports = { generateWithContext }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/ContentAssembly.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: ContentAssembly.js // Description: Assemblage et nettoyage du contenu XML // ======================================== const { logSh } = require('./ErrorReporting'); // Using unified logSh from ErrorReporting /** * Nettoie les balises du template XML * @param {string} xmlString - Le contenu XML à nettoyer * @returns {string} - XML nettoyé */ function cleanStrongTags(xmlString) { logSh('Nettoyage balises du template...', 'DEBUG'); // Enlever toutes les balises et let cleaned = xmlString.replace(/<\/?strong>/g, ''); // Log du nettoyage const strongCount = (xmlString.match(/<\/?strong>/g) || []).length; if (strongCount > 0) { logSh(`${strongCount} balises supprimées`, 'INFO'); } return cleaned; } /** * Remplace toutes les variables CSV dans le XML * @param {string} xmlString - Le contenu XML * @param {object} csvData - Les données CSV * @returns {string} - XML avec variables remplacées */ function replaceAllCSVVariables(xmlString, csvData) { logSh('Remplacement variables CSV...', 'DEBUG'); let result = xmlString; // Variables simples result = result.replace(/\{\{T0\}\}/g, csvData.t0 || ''); result = result.replace(/\{\{MC0\}\}/g, csvData.mc0 || ''); result = result.replace(/\{\{T-1\}\}/g, csvData.tMinus1 || ''); result = result.replace(/\{\{L-1\}\}/g, csvData.lMinus1 || ''); logSh(`Variables simples remplacées: T0="${csvData.t0}", MC0="${csvData.mc0}"`, 'DEBUG'); // Variables multiples const mcPlus1 = (csvData.mcPlus1 || '').split(',').map(s => s.trim()); const tPlus1 = (csvData.tPlus1 || '').split(',').map(s => s.trim()); const lPlus1 = (csvData.lPlus1 || '').split(',').map(s => s.trim()); logSh(`Variables multiples: MC+1[${mcPlus1.length}], T+1[${tPlus1.length}], L+1[${lPlus1.length}]`, 'DEBUG'); // Remplacer MC+1_1, MC+1_2, etc. for (let i = 1; i <= 6; i++) { const mcValue = mcPlus1[i-1] || `[MC+1_${i} non défini]`; const tValue = tPlus1[i-1] || `[T+1_${i} non défini]`; const lValue = lPlus1[i-1] || `[L+1_${i} non défini]`; result = result.replace(new RegExp(`\\{\\{MC\\+1_${i}\\}\\}`, 'g'), mcValue); result = result.replace(new RegExp(`\\{\\{T\\+1_${i}\\}\\}`, 'g'), tValue); result = result.replace(new RegExp(`\\{\\{L\\+1_${i}\\}\\}`, 'g'), lValue); if (mcPlus1[i-1]) { logSh(`MC+1_${i} = "${mcValue}"`, 'DEBUG'); } } // Vérifier qu'il ne reste pas de variables non remplacées const remainingVars = (result.match(/\{\{[^}]+\}\}/g) || []); if (remainingVars.length > 0) { logSh(`ATTENTION: Variables non remplacées: ${remainingVars.join(', ')}`, 'WARNING'); } logSh('Toutes les variables CSV remplacées', 'INFO'); return result; } /** * Injecte le contenu généré dans le XML final * @param {string} cleanXML - XML nettoyé * @param {object} generatedContent - Contenu généré par tag * @param {array} elements - Éléments extraits * @returns {string} - XML final avec contenu injecté */ function injectGeneratedContent(cleanXML, generatedContent, elements) { logSh('🔍 === DEBUG INJECTION MAPPING ===', 'DEBUG'); logSh(`XML reçu: ${cleanXML.length} caractères`, 'DEBUG'); logSh(`Contenu généré: ${Object.keys(generatedContent).length} éléments`, 'DEBUG'); logSh(`Éléments fournis: ${elements.length} éléments`, 'DEBUG'); // Debug: montrer le XML logSh(`🔍 XML début: ${cleanXML}`, 'DEBUG'); // Debug: montrer le contenu généré Object.keys(generatedContent).forEach(key => { logSh(`🔍 Généré [${key}]: "${generatedContent[key]}"`, 'DEBUG'); }); // Debug: montrer les éléments elements.forEach((element, i) => { logSh(`🔍 Element ${i+1}: originalTag="${element.originalTag}", originalFullMatch="${element.originalFullMatch}"`, 'DEBUG'); }); let finalXML = cleanXML; // Créer un mapping tag pur → tag original complet const tagMapping = {}; elements.forEach(element => { tagMapping[element.originalTag] = element.originalFullMatch || element.originalTag; }); logSh(`🔍 TagMapping créé: ${JSON.stringify(tagMapping, null, 2)}`, 'DEBUG'); // Remplacer en utilisant les tags originaux complets Object.keys(generatedContent).forEach(pureTag => { const content = generatedContent[pureTag]; logSh(`🔍 === TRAITEMENT TAG: ${pureTag} ===`, 'DEBUG'); logSh(`🔍 Contenu à injecter: "${content}"`, 'DEBUG'); // Trouver le tag original complet dans le XML const originalTag = findOriginalTagInXML(finalXML, pureTag); logSh(`🔍 Tag original trouvé: ${originalTag ? originalTag : 'AUCUN'}`, 'DEBUG'); if (originalTag) { const beforeLength = finalXML.length; finalXML = finalXML.replace(originalTag, content); const afterLength = finalXML.length; if (beforeLength !== afterLength) { logSh(`✅ SUCCÈS: Remplacé ${originalTag} par contenu (${afterLength - beforeLength + originalTag.length} chars)`, 'DEBUG'); } else { logSh(`❌ ÉCHEC: Replace n'a pas fonctionné pour ${originalTag}`, 'DEBUG'); } } else { // Fallback : essayer avec le tag pur const beforeLength = finalXML.length; finalXML = finalXML.replace(pureTag, content); const afterLength = finalXML.length; logSh(`⚠ FALLBACK ${pureTag}: remplacement ${beforeLength !== afterLength ? 'RÉUSSI' : 'ÉCHOUÉ'}`, 'DEBUG'); logSh(`⚠ Contenu fallback: "${content}"`, 'DEBUG'); } }); // Vérifier les tags restants const remainingTags = (finalXML.match(/\|[^|]*\|/g) || []); if (remainingTags.length > 0) { logSh(`ATTENTION: ${remainingTags.length} tags non remplacés: ${remainingTags.slice(0, 3).join(', ')}...`, 'WARNING'); } logSh('Injection terminée', 'INFO'); return finalXML; } /** * Helper pour trouver le tag original complet dans le XML * @param {string} xmlString - Contenu XML * @param {string} pureTag - Tag pur à rechercher * @returns {string|null} - Tag original trouvé ou null */ function findOriginalTagInXML(xmlString, pureTag) { logSh(`🔍 === RECHERCHE TAG DANS XML ===`, 'DEBUG'); logSh(`🔍 Tag pur recherché: "${pureTag}"`, 'DEBUG'); // Extraire le nom du tag pur : |Titre_H1_1| → Titre_H1_1 const tagName = pureTag.replace(/\|/g, ''); logSh(`🔍 Nom tag extrait: "${tagName}"`, 'DEBUG'); // Chercher tous les tags qui commencent par ce nom (avec espaces optionnels) const regex = new RegExp(`\\|\\s*${tagName}[^|]*\\|`, 'g'); logSh(`🔍 Regex utilisée: ${regex}`, 'DEBUG'); // Debug: montrer tous les tags présents dans le XML const allTags = xmlString.match(/\|[^|]*\|/g) || []; logSh(`🔍 Tags présents dans XML: ${allTags.length}`, 'DEBUG'); allTags.forEach((tag, i) => { logSh(`🔍 ${i+1}. "${tag}"`, 'DEBUG'); }); const matches = xmlString.match(regex); logSh(`🔍 Matches trouvés: ${matches ? matches.length : 0}`, 'DEBUG'); if (matches && matches.length > 0) { logSh(`🔍 Premier match: "${matches[0]}"`, 'DEBUG'); logSh(`✅ Tag original trouvé pour ${pureTag}: ${matches[0]}`, 'DEBUG'); return matches[0]; } logSh(`❌ Aucun tag original trouvé pour ${pureTag}`, 'DEBUG'); return null; } // ============= EXPORTS ============= module.exports = { cleanStrongTags, replaceAllCSVVariables, injectGeneratedContent, findOriginalTagInXML }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/ArticleStorage.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: ArticleStorage.js // Description: Système de sauvegarde articles avec texte compilé uniquement // ======================================== require('dotenv').config(); const { google } = require('googleapis'); const { logSh } = require('./ErrorReporting'); // Configuration Google Sheets const SHEET_CONFIG = { sheetId: '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c' }; /** * NOUVELLE FONCTION : Compiler le contenu de manière organique * Respecte la hiérarchie et les associations naturelles */ async function compileGeneratedTextsOrganic(generatedTexts, elements) { if (!generatedTexts || Object.keys(generatedTexts).length === 0) { return ''; } logSh(`🌱 Compilation ORGANIQUE de ${Object.keys(generatedTexts).length} éléments...`, 'DEBUG'); let compiledParts = []; // 1. DÉTECTER et GROUPER les sections organiques const organicSections = buildOrganicSections(generatedTexts, elements); // 2. COMPILER dans l'ordre naturel organicSections.forEach(section => { if (section.type === 'header_with_content') { // H1, H2, H3 avec leur contenu associé if (section.title) { compiledParts.push(cleanIndividualContent(section.title)); } if (section.content) { compiledParts.push(cleanIndividualContent(section.content)); } } else if (section.type === 'standalone_content') { // Contenu sans titre associé compiledParts.push(cleanIndividualContent(section.content)); } else if (section.type === 'faq_pair') { // Paire question + réponse if (section.question && section.answer) { compiledParts.push(cleanIndividualContent(section.question)); compiledParts.push(cleanIndividualContent(section.answer)); } } }); // 3. Joindre avec espacement naturel const finalText = compiledParts.join('\n\n'); logSh(`✅ Compilation organique terminée: ${finalText.length} caractères`, 'INFO'); return finalText.trim(); } /** * Construire les sections organiques en analysant les associations */ function buildOrganicSections(generatedTexts, elements) { const sections = []; const usedTags = new Set(); // 1. ANALYSER l'ordre original des éléments const originalOrder = elements ? elements.map(el => el.originalTag) : Object.keys(generatedTexts); logSh(`📋 Analyse de ${originalOrder.length} éléments dans l'ordre original...`, 'DEBUG'); // 2. DÉTECTER les associations naturelles for (let i = 0; i < originalOrder.length; i++) { const currentTag = originalOrder[i]; const currentContent = generatedTexts[currentTag]; if (!currentContent || usedTags.has(currentTag)) continue; const currentType = identifyElementType(currentTag); if (currentType === 'titre_h1' || currentType === 'titre_h2' || currentType === 'titre_h3') { // CHERCHER le contenu associé qui suit const associatedContent = findAssociatedContent(originalOrder, i, generatedTexts, usedTags); sections.push({ type: 'header_with_content', title: currentContent, content: associatedContent.content, titleTag: currentTag, contentTag: associatedContent.tag }); usedTags.add(currentTag); if (associatedContent.tag) { usedTags.add(associatedContent.tag); } logSh(` ✓ Section: ${currentType} + contenu associé`, 'DEBUG'); } else if (currentType === 'faq_question') { // CHERCHER la réponse correspondante const matchingAnswer = findMatchingFAQAnswer(currentTag, generatedTexts); if (matchingAnswer) { sections.push({ type: 'faq_pair', question: currentContent, answer: matchingAnswer.content, questionTag: currentTag, answerTag: matchingAnswer.tag }); usedTags.add(currentTag); usedTags.add(matchingAnswer.tag); logSh(` ✓ Paire FAQ: ${currentTag} + ${matchingAnswer.tag}`, 'DEBUG'); } } else if (currentType !== 'faq_reponse') { // CONTENU STANDALONE (pas une réponse FAQ déjà traitée) sections.push({ type: 'standalone_content', content: currentContent, contentTag: currentTag }); usedTags.add(currentTag); logSh(` ✓ Contenu standalone: ${currentType}`, 'DEBUG'); } } logSh(`🏗️ ${sections.length} sections organiques construites`, 'INFO'); return sections; } /** * Trouver le contenu associé à un titre (paragraphe qui suit) */ function findAssociatedContent(originalOrder, titleIndex, generatedTexts, usedTags) { // Chercher dans les éléments suivants for (let j = titleIndex + 1; j < originalOrder.length; j++) { const nextTag = originalOrder[j]; const nextContent = generatedTexts[nextTag]; if (!nextContent || usedTags.has(nextTag)) continue; const nextType = identifyElementType(nextTag); // Si on trouve un autre titre, on s'arrête if (nextType === 'titre_h1' || nextType === 'titre_h2' || nextType === 'titre_h3') { break; } // Si on trouve du contenu (texte, intro), c'est probablement associé if (nextType === 'texte' || nextType === 'intro') { return { content: nextContent, tag: nextTag }; } } return { content: null, tag: null }; } /** * Extraire le numéro d'une FAQ : |Faq_q_1| ou |Faq_a_2| → "1" ou "2" */ function extractFAQNumber(tag) { const match = tag.match(/(\d+)/); return match ? match[1] : null; } /** * Trouver la réponse FAQ correspondant à une question */ function findMatchingFAQAnswer(questionTag, generatedTexts) { // Extraire le numéro : |Faq_q_1| → 1 const questionNumber = extractFAQNumber(questionTag); if (!questionNumber) return null; // Chercher la réponse correspondante for (const tag in generatedTexts) { const tagType = identifyElementType(tag); if (tagType === 'faq_reponse') { const answerNumber = extractFAQNumber(tag); if (answerNumber === questionNumber) { return { content: generatedTexts[tag], tag: tag }; } } } return null; } /** * Nouvelle fonction de sauvegarde avec compilation organique */ async function saveGeneratedArticleOrganic(articleData, csvData, config = {}) { try { logSh('💾 Sauvegarde article avec compilation organique...', 'INFO'); const sheets = await getSheetsClient(); // Vérifier si la sheet existe, sinon la créer let articlesSheet = await getOrCreateSheet(sheets, 'Generated_Articles'); // ===== COMPILATION ORGANIQUE ===== const compiledText = await compileGeneratedTextsOrganic( articleData.generatedTexts, articleData.originalElements // Passer les éléments originaux si disponibles ); logSh(`📝 Texte compilé organiquement: ${compiledText.length} caractères`, 'INFO'); // Métadonnées avec format français const now = new Date(); const frenchTimestamp = formatDateToFrench(now); // UTILISER le slug du CSV (colonne A du Google Sheet source) // Le slug doit venir de csvData.slug (récupéré via getBrainConfig) const slug = csvData.slug || generateSlugFromContent(csvData.mc0, csvData.t0); const metadata = { timestamp: frenchTimestamp, slug: slug, mc0: csvData.mc0, t0: csvData.t0, personality: csvData.personality?.nom || 'Unknown', antiDetectionLevel: config.antiDetectionLevel || 'MVP', elementsCount: Object.keys(articleData.generatedTexts || {}).length, textLength: compiledText.length, wordCount: countWords(compiledText), llmUsed: config.llmUsed || 'openai', validationStatus: articleData.validationReport?.status || 'unknown' }; // Préparer la ligne de données const row = [ metadata.timestamp, metadata.slug, metadata.mc0, metadata.t0, metadata.personality, metadata.antiDetectionLevel, compiledText, // ← TEXTE ORGANIQUE metadata.textLength, metadata.wordCount, metadata.elementsCount, metadata.llmUsed, metadata.validationStatus, '', '', '', '', JSON.stringify({ csvData: csvData, config: config, stats: metadata }) ]; // DEBUG: Vérifier le slug généré logSh(`💾 Sauvegarde avec slug: "${metadata.slug}" (colonne B)`, 'DEBUG'); // Ajouter la ligne aux données await sheets.spreadsheets.values.append({ spreadsheetId: SHEET_CONFIG.sheetId, range: 'Generated_Articles!A:Q', valueInputOption: 'USER_ENTERED', resource: { values: [row] } }); // Récupérer le numéro de ligne pour l'ID article const response = await sheets.spreadsheets.values.get({ spreadsheetId: SHEET_CONFIG.sheetId, range: 'Generated_Articles!A:A' }); const articleId = response.data.values ? response.data.values.length - 1 : 1; logSh(`✅ Article organique sauvé: ID ${articleId}, ${metadata.wordCount} mots`, 'INFO'); return { articleId: articleId, textLength: metadata.textLength, wordCount: metadata.wordCount, sheetRow: response.data.values ? response.data.values.length : 2 }; } catch (error) { logSh(`❌ Erreur sauvegarde organique: ${error.toString()}`, 'ERROR'); throw error; } } /** * Générer un slug à partir du contenu MC0 et T0 */ function generateSlugFromContent(mc0, t0) { if (!mc0 && !t0) return 'article-generated'; const source = mc0 || t0; return source .toString() .toLowerCase() .replace(/[àáâäã]/g, 'a') .replace(/[èéêë]/g, 'e') .replace(/[ìíîï]/g, 'i') .replace(/[òóôöõ]/g, 'o') .replace(/[ùúûü]/g, 'u') .replace(/[ç]/g, 'c') .replace(/[ñ]/g, 'n') .replace(/[^a-z0-9\s-]/g, '') // Enlever caractères spéciaux .replace(/\s+/g, '-') // Espaces -> tirets .replace(/-+/g, '-') // Éviter doubles tirets .replace(/^-+|-+$/g, '') // Enlever tirets début/fin .substring(0, 50); // Limiter longueur } /** * Identifier le type d'élément par son tag */ function identifyElementType(tag) { const cleanTag = tag.toLowerCase().replace(/[|{}]/g, ''); if (cleanTag.includes('titre_h1') || cleanTag.includes('h1')) return 'titre_h1'; if (cleanTag.includes('titre_h2') || cleanTag.includes('h2')) return 'titre_h2'; if (cleanTag.includes('titre_h3') || cleanTag.includes('h3')) return 'titre_h3'; if (cleanTag.includes('intro')) return 'intro'; if (cleanTag.includes('faq_q') || cleanTag.includes('faq_question')) return 'faq_question'; if (cleanTag.includes('faq_a') || cleanTag.includes('faq_reponse')) return 'faq_reponse'; return 'texte'; // Par défaut } /** * Nettoyer un contenu individuel */ function cleanIndividualContent(content) { if (!content) return ''; let cleaned = content.toString(); // 1. Supprimer les balises HTML cleaned = cleaned.replace(/<[^>]*>/g, ''); // 2. Décoder les entités HTML cleaned = cleaned.replace(/</g, '<'); cleaned = cleaned.replace(/>/g, '>'); cleaned = cleaned.replace(/&/g, '&'); cleaned = cleaned.replace(/"/g, '"'); cleaned = cleaned.replace(/'/g, "'"); cleaned = cleaned.replace(/ /g, ' '); // 3. Nettoyer les espaces cleaned = cleaned.replace(/\s+/g, ' '); cleaned = cleaned.replace(/\n\s+/g, '\n'); // 4. Supprimer les caractères de contrôle étranges cleaned = cleaned.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); return cleaned.trim(); } /** * Créer la sheet de stockage avec headers appropriés */ async function createArticlesStorageSheet(sheets) { logSh('🗄️ Création sheet Generated_Articles...', 'INFO'); try { // Créer la nouvelle sheet await sheets.spreadsheets.batchUpdate({ spreadsheetId: SHEET_CONFIG.sheetId, resource: { requests: [{ addSheet: { properties: { title: 'Generated_Articles' } } }] } }); // Headers const headers = [ 'Timestamp', 'Slug', 'MC0', 'T0', 'Personality', 'AntiDetection_Level', 'Compiled_Text', // ← COLONNE PRINCIPALE 'Text_Length', 'Word_Count', 'Elements_Count', 'LLM_Used', 'Validation_Status', 'GPTZero_Score', // Scores détecteurs (à remplir) 'Originality_Score', 'CopyLeaks_Score', 'Human_Quality_Score', 'Full_Metadata_JSON' // Backup complet ]; // Ajouter les headers await sheets.spreadsheets.values.update({ spreadsheetId: SHEET_CONFIG.sheetId, range: 'Generated_Articles!A1:Q1', valueInputOption: 'USER_ENTERED', resource: { values: [headers] } }); // Formatter les headers await sheets.spreadsheets.batchUpdate({ spreadsheetId: SHEET_CONFIG.sheetId, resource: { requests: [{ repeatCell: { range: { sheetId: await getSheetIdByName(sheets, 'Generated_Articles'), startRowIndex: 0, endRowIndex: 1, startColumnIndex: 0, endColumnIndex: headers.length }, cell: { userEnteredFormat: { textFormat: { bold: true }, backgroundColor: { red: 0.878, green: 0.878, blue: 0.878 }, horizontalAlignment: 'CENTER' } }, fields: 'userEnteredFormat(textFormat,backgroundColor,horizontalAlignment)' } }] } }); logSh('✅ Sheet Generated_Articles créée avec succès', 'INFO'); return true; } catch (error) { logSh(`❌ Erreur création sheet: ${error.toString()}`, 'ERROR'); throw error; } } /** * Formater date au format français DD/MM/YYYY HH:mm:ss */ function formatDateToFrench(date) { // Utiliser toLocaleString avec le format français return date.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Europe/Paris' }).replace(',', ''); } /** * Compter les mots dans un texte */ function countWords(text) { if (!text || text.trim() === '') return 0; return text.trim().split(/\s+/).length; } /** * Récupérer un article sauvé par ID */ async function getStoredArticle(articleId) { try { const sheets = await getSheetsClient(); const rowNumber = articleId + 2; // +2 car header + 0-indexing const response = await sheets.spreadsheets.values.get({ spreadsheetId: SHEET_CONFIG.sheetId, range: `Generated_Articles!A${rowNumber}:Q${rowNumber}` }); if (!response.data.values || response.data.values.length === 0) { throw new Error(`Article ${articleId} non trouvé`); } const data = response.data.values[0]; return { articleId: articleId, timestamp: data[0], slug: data[1], mc0: data[2], t0: data[3], personality: data[4], antiDetectionLevel: data[5], compiledText: data[6], // ← TEXTE PUR textLength: data[7], wordCount: data[8], elementsCount: data[9], llmUsed: data[10], validationStatus: data[11], gptZeroScore: data[12], originalityScore: data[13], copyLeaksScore: data[14], humanScore: data[15], fullMetadata: data[16] ? JSON.parse(data[16]) : null }; } catch (error) { logSh(`❌ Erreur récupération article ${articleId}: ${error.toString()}`, 'ERROR'); throw error; } } /** * Lister les derniers articles générés */ async function getRecentArticles(limit = 10) { try { const sheets = await getSheetsClient(); const response = await sheets.spreadsheets.values.get({ spreadsheetId: SHEET_CONFIG.sheetId, range: 'Generated_Articles!A:L' }); if (!response.data.values || response.data.values.length <= 1) { return []; // Pas de données ou seulement headers } const data = response.data.values.slice(1); // Exclure headers const startIndex = Math.max(0, data.length - limit); const recentData = data.slice(startIndex); return recentData.map((row, index) => ({ articleId: startIndex + index, timestamp: row[0], slug: row[1], mc0: row[2], personality: row[4], antiDetectionLevel: row[5], wordCount: row[8], validationStatus: row[11] })).reverse(); // Plus récents en premier } catch (error) { logSh(`❌ Erreur liste articles récents: ${error.toString()}`, 'ERROR'); return []; } } /** * Mettre à jour les scores de détection d'un article */ async function updateDetectionScores(articleId, scores) { try { const sheets = await getSheetsClient(); const rowNumber = articleId + 2; const updates = []; // Colonnes des scores : M, N, O (GPTZero, Originality, CopyLeaks) if (scores.gptzero !== undefined) { updates.push({ range: `Generated_Articles!M${rowNumber}`, values: [[scores.gptzero]] }); } if (scores.originality !== undefined) { updates.push({ range: `Generated_Articles!N${rowNumber}`, values: [[scores.originality]] }); } if (scores.copyleaks !== undefined) { updates.push({ range: `Generated_Articles!O${rowNumber}`, values: [[scores.copyleaks]] }); } if (updates.length > 0) { await sheets.spreadsheets.values.batchUpdate({ spreadsheetId: SHEET_CONFIG.sheetId, resource: { valueInputOption: 'USER_ENTERED', data: updates } }); } logSh(`✅ Scores détection mis à jour pour article ${articleId}`, 'INFO'); } catch (error) { logSh(`❌ Erreur maj scores article ${articleId}: ${error.toString()}`, 'ERROR'); throw error; } } // ============= HELPERS GOOGLE SHEETS ============= /** * Obtenir le client Google Sheets authentifié */ async function getSheetsClient() { const auth = new google.auth.GoogleAuth({ credentials: { client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n') }, scopes: ['https://www.googleapis.com/auth/spreadsheets'] }); const authClient = await auth.getClient(); const sheets = google.sheets({ version: 'v4', auth: authClient }); return sheets; } /** * Obtenir ou créer une sheet */ async function getOrCreateSheet(sheets, sheetName) { try { // Vérifier si la sheet existe const response = await sheets.spreadsheets.get({ spreadsheetId: SHEET_CONFIG.sheetId }); const existingSheet = response.data.sheets.find( sheet => sheet.properties.title === sheetName ); if (existingSheet) { return existingSheet; } else { // Créer la sheet si elle n'existe pas if (sheetName === 'Generated_Articles') { await createArticlesStorageSheet(sheets); return await getOrCreateSheet(sheets, sheetName); // Récursif pour récupérer la sheet créée } throw new Error(`Sheet ${sheetName} non supportée pour création automatique`); } } catch (error) { logSh(`❌ Erreur accès/création sheet ${sheetName}: ${error.toString()}`, 'ERROR'); throw error; } } /** * Obtenir l'ID d'une sheet par son nom */ async function getSheetIdByName(sheets, sheetName) { const response = await sheets.spreadsheets.get({ spreadsheetId: SHEET_CONFIG.sheetId }); const sheet = response.data.sheets.find( s => s.properties.title === sheetName ); return sheet ? sheet.properties.sheetId : null; } // ============= EXPORTS ============= module.exports = { compileGeneratedTextsOrganic, buildOrganicSections, findAssociatedContent, extractFAQNumber, findMatchingFAQAnswer, saveGeneratedArticleOrganic, identifyElementType, cleanIndividualContent, createArticlesStorageSheet, formatDateToFrench, countWords, getStoredArticle, getRecentArticles, updateDetectionScores, getSheetsClient, getOrCreateSheet, getSheetIdByName, generateSlugFromContent }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/DigitalOceanWorkflow.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: DigitalOceanWorkflow.js - REFACTORISÉ POUR NODE.JS // RESPONSABILITÉ: Orchestration + Interface Digital Ocean UNIQUEMENT // ======================================== const crypto = require('crypto'); const axios = require('axios'); const { GoogleSpreadsheet } = require('google-spreadsheet'); const { JWT } = require('google-auth-library'); // Import des autres modules du projet (à adapter selon votre structure) const { logSh } = require('./ErrorReporting'); const { handleFullWorkflow } = require('./Main'); const { getPersonalities, selectPersonalityWithAI } = require('./BrainConfig'); // ============= CONFIGURATION DIGITAL OCEAN ============= const DO_CONFIG = { endpoint: 'https://autocollant.fra1.digitaloceanspaces.com', bucketName: 'autocollant', accessKeyId: 'DO801XTYPE968NZGAQM3', secretAccessKey: '5aCCBiS9K+J8gsAe3M3/0GlliHCNjtLntwla1itCN1s', region: 'fra1' }; // Configuration Google Sheets const SHEET_CONFIG = { sheetId: '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c', serviceAccountEmail: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, privateKey: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'), // Alternative: utiliser fichier JSON directement keyFile: './seo-generator-470715-85d4a971c1af.json' }; async function deployArticle({ path, html, dryRun = false, ...rest }) { if (!path || typeof html !== 'string') { const err = new Error('deployArticle: invalid payload (requires {path, html})'); err.code = 'E_PAYLOAD'; throw err; } if (dryRun) { return { ok: true, dryRun: true, length: html.length, path, meta: rest || {} }; } // --- Impl réelle à toi ici (upload DO Spaces / API / SSH etc.) --- // return await realDeploy({ path, html, ...rest }); // Placeholder pour ne pas casser l'appel si pas encore implémenté return { ok: true, dryRun: false, path, length: html.length }; } module.exports.deployArticle = module.exports.deployArticle || deployArticle; // ============= TRIGGER PRINCIPAL REMPLACÉ PAR WEBHOOK/API ============= /** * Point d'entrée pour déclencher le workflow * Remplace le trigger onEdit d'Apps Script * @param {number} rowNumber - Numéro de ligne à traiter * @returns {Promise} - Résultat du workflow */ async function triggerAutonomousWorkflow(rowNumber) { try { logSh('🚀 TRIGGER AUTONOME DÉCLENCHÉ (Digital Ocean)', 'INFO'); // Anti-bouncing simulé await new Promise(resolve => setTimeout(resolve, 2000)); return await runAutonomousWorkflowFromTrigger(rowNumber); } catch (error) { logSh(`❌ Erreur trigger autonome DO: ${error.toString()}`, 'ERROR'); throw error; } } /** * ORCHESTRATEUR: Prépare les données et délègue à Main.js */ async function runAutonomousWorkflowFromTrigger(rowNumber) { const startTime = Date.now(); try { logSh(`🎬 ORCHESTRATION AUTONOME - LIGNE ${rowNumber}`, 'INFO'); // 1. LIRE DONNÉES CSV + XML FILENAME const csvData = await readCSVDataWithXMLFileName(rowNumber); logSh(`✅ CSV: ${csvData.mc0}, XML: ${csvData.xmlFileName}`, 'INFO'); // 2. RÉCUPÉRER XML DEPUIS DIGITAL OCEAN const xmlTemplate = await fetchXMLFromDigitalOceanSimple(csvData.xmlFileName); logSh(`✅ XML récupéré: ${xmlTemplate.length} caractères`, 'INFO'); // 3. 🎯 DÉLÉGUER LE WORKFLOW À MAIN.JS const workflowData = { rowNumber: rowNumber, xmlTemplate: Buffer.from(xmlTemplate).toString('base64'), // Encoder comme Make.com csvData: csvData, source: 'digital_ocean_autonomous' }; const result = await handleFullWorkflow(workflowData); const duration = Date.now() - startTime; logSh(`🏆 ORCHESTRATION TERMINÉE en ${Math.round(duration/1000)}s`, 'INFO'); // 4. MARQUER LIGNE COMME TRAITÉE await markRowAsProcessed(rowNumber, result); return result; } catch (error) { const duration = Date.now() - startTime; logSh(`❌ ERREUR ORCHESTRATION: ${error.toString()}`, 'ERROR'); await markRowAsError(rowNumber, error.toString()); throw error; } } // ============= INTERFACE DIGITAL OCEAN ============= async function fetchXMLFromDigitalOceanSimple(fileName) { const filePath = `wp-content/XML/${fileName}`; const fileUrl = `${DO_CONFIG.endpoint}/${filePath}`; try { const response = await axios.get(fileUrl); // Sans auth return response.data; } catch (error) { throw new Error(`Fichier non accessible: ${error.message}`); } } /** * Récupérer XML depuis Digital Ocean Spaces avec authentification */ async function fetchXMLFromDigitalOcean(fileName) { if (!fileName) { throw new Error('Nom de fichier XML requis'); } const filePath = `wp-content/XML/${fileName}`; logSh(`🌊 Récupération XML: ${fileName} , ${filePath}`, 'DEBUG'); const fileUrl = `${DO_CONFIG.endpoint}/${filePath}`; logSh(`🔗 URL complète: ${fileUrl}`, 'DEBUG'); const signature = generateAWSSignature(filePath); try { const response = await axios.get(fileUrl, { headers: signature.headers }); logSh(`📡 Response code: ${response.status}`, 'DEBUG'); logSh(`📄 Response: ${response.data.toString()}`, 'DEBUG'); if (response.status === 200) { return response.data; } else { throw new Error(`HTTP ${response.status}: ${response.data}`); } } catch (error) { logSh(`❌ Erreur DO complète: ${error.toString()}`, 'ERROR'); throw error; } } /** * Lire données CSV avec nom fichier XML (colonne J) */ async function readCSVDataWithXMLFileName(rowNumber) { try { // Configuration Google Sheets - avec fallback sur fichier JSON let serviceAccountAuth; if (SHEET_CONFIG.serviceAccountEmail && SHEET_CONFIG.privateKey) { // Utiliser variables d'environnement serviceAccountAuth = new JWT({ email: SHEET_CONFIG.serviceAccountEmail, key: SHEET_CONFIG.privateKey, scopes: ['https://www.googleapis.com/auth/spreadsheets'] }); } else { // Utiliser fichier JSON serviceAccountAuth = new JWT({ keyFile: SHEET_CONFIG.keyFile, scopes: ['https://www.googleapis.com/auth/spreadsheets'] }); } const doc = new GoogleSpreadsheet(SHEET_CONFIG.sheetId, serviceAccountAuth); await doc.loadInfo(); const sheet = doc.sheetsByTitle['instructions']; if (!sheet) { throw new Error('Sheet "instructions" non trouvée'); } await sheet.loadCells(`A${rowNumber}:I${rowNumber}`); const slug = sheet.getCell(rowNumber - 1, 0).value; const t0 = sheet.getCell(rowNumber - 1, 1).value; const mc0 = sheet.getCell(rowNumber - 1, 2).value; const tMinus1 = sheet.getCell(rowNumber - 1, 3).value; const lMinus1 = sheet.getCell(rowNumber - 1, 4).value; const mcPlus1 = sheet.getCell(rowNumber - 1, 5).value; const tPlus1 = sheet.getCell(rowNumber - 1, 6).value; const lPlus1 = sheet.getCell(rowNumber - 1, 7).value; const xmlFileName = sheet.getCell(rowNumber - 1, 8).value; if (!xmlFileName || xmlFileName.toString().trim() === '') { throw new Error(`Nom fichier XML manquant colonne I, ligne ${rowNumber}`); } let cleanFileName = xmlFileName.toString().trim(); if (!cleanFileName.endsWith('.xml')) { cleanFileName += '.xml'; } // Récupérer personnalité (délègue au système existant BrainConfig.js) const personalities = await getPersonalities(); // Pas de paramètre, lit depuis JSON const selectedPersonality = await selectPersonalityWithAI(mc0, t0, personalities); return { rowNumber: rowNumber, slug: slug, t0: t0, mc0: mc0, tMinus1: tMinus1, lMinus1: lMinus1, mcPlus1: mcPlus1, tPlus1: tPlus1, lPlus1: lPlus1, xmlFileName: cleanFileName, personality: selectedPersonality }; } catch (error) { logSh(`❌ Erreur lecture CSV: ${error.toString()}`, 'ERROR'); throw error; } } // ============= STATUTS ET VALIDATION ============= /** * Vérifier si le workflow doit être déclenché * En Node.js, cette logique sera adaptée selon votre stratégie (webhook, polling, etc.) */ function shouldTriggerWorkflow(rowNumber, xmlFileName) { if (!rowNumber || rowNumber <= 1) { return false; } if (!xmlFileName || xmlFileName.toString().trim() === '') { logSh('⚠️ Pas de fichier XML (colonne J), workflow ignoré', 'WARNING'); return false; } return true; } async function markRowAsProcessed(rowNumber, result) { try { // Configuration Google Sheets - avec fallback sur fichier JSON let serviceAccountAuth; if (SHEET_CONFIG.serviceAccountEmail && SHEET_CONFIG.privateKey) { serviceAccountAuth = new JWT({ email: SHEET_CONFIG.serviceAccountEmail, key: SHEET_CONFIG.privateKey, scopes: ['https://www.googleapis.com/auth/spreadsheets'] }); } else { serviceAccountAuth = new JWT({ keyFile: SHEET_CONFIG.keyFile, scopes: ['https://www.googleapis.com/auth/spreadsheets'] }); } const doc = new GoogleSpreadsheet(SHEET_CONFIG.sheetId, serviceAccountAuth); await doc.loadInfo(); const sheet = doc.sheetsByTitle['instructions']; // Vérifier et ajouter headers si nécessaire await sheet.loadCells('K1:N1'); if (!sheet.getCell(0, 10).value) { sheet.getCell(0, 10).value = 'Status'; sheet.getCell(0, 11).value = 'Processed_At'; sheet.getCell(0, 12).value = 'Article_ID'; sheet.getCell(0, 13).value = 'Source'; await sheet.saveUpdatedCells(); } // Marquer la ligne await sheet.loadCells(`K${rowNumber}:N${rowNumber}`); sheet.getCell(rowNumber - 1, 10).value = '✅ DO_SUCCESS'; sheet.getCell(rowNumber - 1, 11).value = new Date().toISOString(); sheet.getCell(rowNumber - 1, 12).value = result.articleStorage?.articleId || ''; sheet.getCell(rowNumber - 1, 13).value = 'Digital Ocean'; await sheet.saveUpdatedCells(); logSh(`✅ Ligne ${rowNumber} marquée comme traitée`, 'INFO'); } catch (error) { logSh(`⚠️ Erreur marquage statut: ${error.toString()}`, 'WARNING'); } } async function markRowAsError(rowNumber, errorMessage) { try { // Configuration Google Sheets - avec fallback sur fichier JSON let serviceAccountAuth; if (SHEET_CONFIG.serviceAccountEmail && SHEET_CONFIG.privateKey) { serviceAccountAuth = new JWT({ email: SHEET_CONFIG.serviceAccountEmail, key: SHEET_CONFIG.privateKey, scopes: ['https://www.googleapis.com/auth/spreadsheets'] }); } else { serviceAccountAuth = new JWT({ keyFile: SHEET_CONFIG.keyFile, scopes: ['https://www.googleapis.com/auth/spreadsheets'] }); } const doc = new GoogleSpreadsheet(SHEET_CONFIG.sheetId, serviceAccountAuth); await doc.loadInfo(); const sheet = doc.sheetsByTitle['instructions']; await sheet.loadCells(`K${rowNumber}:N${rowNumber}`); sheet.getCell(rowNumber - 1, 10).value = '❌ DO_ERROR'; sheet.getCell(rowNumber - 1, 11).value = new Date().toISOString(); sheet.getCell(rowNumber - 1, 12).value = errorMessage.substring(0, 100); sheet.getCell(rowNumber - 1, 13).value = 'DO Error'; await sheet.saveUpdatedCells(); } catch (error) { logSh(`⚠️ Erreur marquage erreur: ${error.toString()}`, 'WARNING'); } } // ============= SIGNATURE AWS V4 ============= function generateAWSSignature(filePath) { const now = new Date(); const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, ''); const timeStamp = now.toISOString().replace(/[-:]/g, '').slice(0, -5) + 'Z'; const headers = { 'Host': DO_CONFIG.endpoint.replace('https://', ''), 'X-Amz-Date': timeStamp, 'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD' }; const credentialScope = `${dateStamp}/${DO_CONFIG.region}/s3/aws4_request`; const canonicalHeaders = Object.keys(headers) .sort() .map(key => `${key.toLowerCase()}:${headers[key]}`) .join('\n'); const signedHeaders = Object.keys(headers) .map(key => key.toLowerCase()) .sort() .join(';'); const canonicalRequest = [ 'GET', `/${filePath}`, '', canonicalHeaders + '\n', signedHeaders, 'UNSIGNED-PAYLOAD' ].join('\n'); const stringToSign = [ 'AWS4-HMAC-SHA256', timeStamp, credentialScope, crypto.createHash('sha256').update(canonicalRequest).digest('hex') ].join('\n'); // Calculs HMAC étape par étape const kDate = crypto.createHmac('sha256', 'AWS4' + DO_CONFIG.secretAccessKey).update(dateStamp).digest(); const kRegion = crypto.createHmac('sha256', kDate).update(DO_CONFIG.region).digest(); const kService = crypto.createHmac('sha256', kRegion).update('s3').digest(); const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest(); const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex'); headers['Authorization'] = `AWS4-HMAC-SHA256 Credential=${DO_CONFIG.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; return { headers: headers }; } // ============= SETUP ET TEST ============= /** * Configuration du trigger autonome - Remplacé par webhook ou polling en Node.js */ function setupAutonomousTrigger() { logSh('⚙️ Configuration trigger autonome Digital Ocean...', 'INFO'); // En Node.js, vous pourriez utiliser: // - Express.js avec webhooks // - Cron jobs avec node-cron // - Polling de la Google Sheet // - WebSocket connections logSh('✅ Configuration prête pour webhooks/polling Node.js', 'INFO'); logSh('🎯 Mode: Webhook/API → Digital Ocean → Main.js', 'INFO'); } async function testDigitalOceanConnection() { logSh('🧪 Test connexion Digital Ocean...', 'INFO'); try { const testFiles = ['template1.xml', 'plaque-rue.xml', 'test.xml']; for (const fileName of testFiles) { try { const content = await fetchXMLFromDigitalOceanSimple(fileName); logSh(`✅ Fichier '${fileName}' accessible (${content.length} chars)`, 'INFO'); return true; } catch (error) { logSh(`⚠️ '${fileName}' non accessible: ${error.toString()}`, 'DEBUG'); } } logSh('❌ Aucun fichier test accessible dans DO', 'ERROR'); return false; } catch (error) { logSh(`❌ Test DO échoué: ${error.toString()}`, 'ERROR'); return false; } } // ============= EXPORTS ============= module.exports = { triggerAutonomousWorkflow, runAutonomousWorkflowFromTrigger, fetchXMLFromDigitalOcean, fetchXMLFromDigitalOceanSimple, readCSVDataWithXMLFileName, markRowAsProcessed, markRowAsError, testDigitalOceanConnection, setupAutonomousTrigger, DO_CONFIG }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/Main.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: lib/main.js - CONVERTI POUR NODE.JS // RESPONSABILITÉ: COEUR DU WORKFLOW DE GÉNÉRATION // ======================================== // 🔧 CONFIGURATION ENVIRONNEMENT require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); // 🔄 IMPORTS NODE.JS (remplace les dépendances Apps Script) const { getBrainConfig } = require('./BrainConfig'); const { extractElements, buildSmartHierarchy } = require('./ElementExtraction'); const { generateMissingKeywords } = require('./MissingKeywords'); const { generateWithContext } = require('./ContentGeneration'); const { injectGeneratedContent, cleanStrongTags } = require('./ContentAssembly'); const { validateWorkflowIntegrity, logSh } = require('./ErrorReporting'); const { saveGeneratedArticleOrganic } = require('./ArticleStorage'); const { tracer } = require('./trace.js'); const { fetchXMLFromDigitalOcean } = require('./DigitalOceanWorkflow'); const { spawn } = require('child_process'); const path = require('path'); // Variable pour éviter de relancer Edge plusieurs fois let logViewerLaunched = false; /** * Lancer le log viewer dans Edge */ function launchLogViewer() { if (logViewerLaunched || process.env.NODE_ENV === 'test') return; try { const logViewerPath = path.join(__dirname, '..', 'tools', 'logs-viewer.html'); const fileUrl = `file:///${logViewerPath.replace(/\\/g, '/')}`; // Détecter l'environnement et adapter la commande const isWSL = process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP; const isWindows = process.platform === 'win32'; if (isWindows && !isWSL) { // Windows natif const edgeProcess = spawn('cmd', ['/c', 'start', 'msedge', fileUrl], { detached: true, stdio: 'ignore' }); edgeProcess.unref(); } else if (isWSL) { // WSL - utiliser cmd.exe via /mnt/c/Windows/System32/ const edgeProcess = spawn('/mnt/c/Windows/System32/cmd.exe', ['/c', 'start', 'msedge', fileUrl], { detached: true, stdio: 'ignore' }); edgeProcess.unref(); } else { // Linux/Mac - essayer xdg-open ou open const command = process.platform === 'darwin' ? 'open' : 'xdg-open'; const browserProcess = spawn(command, [fileUrl], { detached: true, stdio: 'ignore' }); browserProcess.unref(); } logViewerLaunched = true; logSh('🌐 Log viewer lancé', 'INFO'); } catch (error) { logSh(`⚠️ Impossible d'ouvrir le log viewer: ${error.message}`, 'WARNING'); } } /** * COEUR DU WORKFLOW - Compatible Make.com ET Digital Ocean ET Node.js * @param {object} data - Données du workflow * @param {string} data.xmlTemplate - XML template (base64 encodé) * @param {object} data.csvData - Données CSV ou rowNumber * @param {string} data.source - 'make_com' | 'digital_ocean_autonomous' | 'node_server' */ async function handleFullWorkflow(data) { // Lancer le log viewer au début du workflow launchLogViewer(); return await tracer.run('Main.handleFullWorkflow()', async () => { await tracer.annotate({ source: data.source || 'node_server', mc0: data.csvData?.mc0 || data.rowNumber }); // 1. PRÉPARER LES DONNÉES CSV const csvData = await tracer.run('Main.prepareCSVData()', async () => { const result = await prepareCSVData(data); await tracer.event(`CSV préparé: ${result.mc0}`, { csvKeys: Object.keys(result) }); return result; }, { rowNumber: data.rowNumber, source: data.source }); // 2. DÉCODER LE XML TEMPLATE const xmlString = await tracer.run('Main.decodeXMLTemplate()', async () => { const result = decodeXMLTemplate(data.xmlTemplate); await tracer.event(`XML décodé: ${result.length} caractères`); return result; }, { templateLength: data.xmlTemplate?.length }); // 3. PREPROCESSING XML const processedXML = await tracer.run('Main.preprocessXML()', async () => { const result = preprocessXML(xmlString); await tracer.event('XML préprocessé'); global.currentXmlTemplate = result; return result; }, { originalLength: xmlString?.length }); // 4. EXTRAIRE ÉLÉMENTS const elements = await tracer.run('ElementExtraction.extractElements()', async () => { const result = await extractElements(processedXML, csvData); await tracer.event(`${result.length} éléments extraits`); return result; }, { xmlLength: processedXML?.length, mc0: csvData.mc0 }); // 5. GÉNÉRER MOTS-CLÉS MANQUANTS const finalElements = await tracer.run('MissingKeywords.generateMissingKeywords()', async () => { const updatedElements = await generateMissingKeywords(elements, csvData); const result = Object.keys(updatedElements).length > 0 ? updatedElements : elements; await tracer.event('Mots-clés manquants traités'); return result; }, { elementsCount: elements.length, mc0: csvData.mc0 }); // 6. CONSTRUIRE HIÉRARCHIE INTELLIGENTE const hierarchy = await tracer.run('ElementExtraction.buildSmartHierarchy()', async () => { const result = await buildSmartHierarchy(finalElements); await tracer.event(`Hiérarchie construite: ${Object.keys(result).length} sections`); return result; }, { finalElementsCount: finalElements.length }); // 7. 🎯 GÉNÉRATION AVEC SELECTIVE ENHANCEMENT (Phase 2) const generatedContent = await tracer.run('ContentGeneration.generateWithContext()', async () => { const result = await generateWithContext(hierarchy, csvData); await tracer.event(`Contenu généré: ${Object.keys(result).length} éléments`); return result; }, { elementsCount: Object.keys(hierarchy).length, personality: csvData.personality?.nom }); // 8. ASSEMBLER XML FINAL const finalXML = await tracer.run('ContentAssembly.injectGeneratedContent()', async () => { const result = injectGeneratedContent(processedXML, generatedContent, finalElements); await tracer.event('XML final assemblé'); return result; }, { contentPieces: Object.keys(generatedContent).length, elementsCount: finalElements.length }); // 9. VALIDATION INTÉGRITÉ const validationReport = await tracer.run('ErrorReporting.validateWorkflowIntegrity()', async () => { const result = validateWorkflowIntegrity(finalElements, generatedContent, finalXML, csvData); await tracer.event(`Validation: ${result.status}`); return result; }, { finalXMLLength: finalXML?.length, contentKeys: Object.keys(generatedContent).length }); // 10. SAUVEGARDE ARTICLE const articleStorage = await tracer.run('Main.saveArticle()', async () => { const result = await saveArticle(finalXML, generatedContent, finalElements, csvData, data.source); if (result) { await tracer.event(`Article sauvé: ID ${result.articleId}`); } return result; }, { source: data.source, mc0: csvData.mc0, elementsCount: finalElements.length }); // 11. RÉPONSE FINALE const response = await tracer.run('Main.buildWorkflowResponse()', async () => { const result = await buildWorkflowResponse(finalXML, generatedContent, finalElements, csvData, validationReport, articleStorage, data.source); await tracer.event(`Response keys: ${Object.keys(result).join(', ')}`); return result; }, { validationStatus: validationReport?.status, articleId: articleStorage?.articleId }); return response; }, { source: data.source || 'node_server', rowNumber: data.rowNumber, hasXMLTemplate: !!data.xmlTemplate }); } // ============= PRÉPARATION DONNÉES ============= /** * Préparer les données CSV selon la source - ASYNC pour Node.js * RÉCUPÈRE: Google Sheets (données CSV) + Digital Ocean (XML template) */ async function prepareCSVData(data) { if (data.csvData && data.csvData.mc0) { // Données déjà préparées (Digital Ocean ou direct) return data.csvData; } else if (data.rowNumber) { // 1. RÉCUPÉRER DONNÉES CSV depuis Google Sheet (OBLIGATOIRE) await logSh(`🧠 Récupération données CSV ligne ${data.rowNumber}...`, 'INFO'); const config = await getBrainConfig(data.rowNumber); if (!config.success) { await logSh('❌ ÉCHEC: Impossible de récupérer les données Google Sheets', 'ERROR'); throw new Error('FATAL: Google Sheets inaccessible - arrêt du workflow'); } // 2. VÉRIFIER XML FILENAME depuis Google Sheet (colonne I) const xmlFileName = config.data.xmlFileName; if (!xmlFileName || xmlFileName.trim() === '') { await logSh('❌ ÉCHEC: Nom fichier XML manquant (colonne I Google Sheets)', 'ERROR'); throw new Error('FATAL: XML filename manquant - arrêt du workflow'); } await logSh(`📋 CSV récupéré: ${config.data.mc0}`, 'INFO'); await logSh(`📄 XML filename: ${xmlFileName}`, 'INFO'); // 3. RÉCUPÉRER XML CONTENT depuis Digital Ocean avec AUTH (OBLIGATOIRE) await logSh(`🌊 Récupération XML template depuis Digital Ocean (avec signature AWS)...`, 'INFO'); let xmlContent; try { xmlContent = await fetchXMLFromDigitalOcean(xmlFileName); await logSh(`✅ XML récupéré: ${xmlContent.length} caractères`, 'INFO'); } catch (digitalOceanError) { await logSh(`❌ ÉCHEC: Digital Ocean inaccessible - ${digitalOceanError.message}`, 'ERROR'); throw new Error(`FATAL: Digital Ocean échec - arrêt du workflow: ${digitalOceanError.message}`); } // 4. ENCODER XML pour le workflow (comme Make.com) // Si on a récupéré un fichier XML, l'utiliser. Sinon utiliser le template par défaut déjà dans config.data.xmlTemplate if (xmlContent) { data.xmlTemplate = Buffer.from(xmlContent).toString('base64'); await logSh('🔄 XML depuis Digital Ocean encodé base64 pour le workflow', 'DEBUG'); } else if (config.data.xmlTemplate) { data.xmlTemplate = Buffer.from(config.data.xmlTemplate).toString('base64'); await logSh('🔄 XML template par défaut encodé base64 pour le workflow', 'DEBUG'); } return config.data; } else { throw new Error('FATAL: Données CSV invalides - rowNumber requis'); } } /** * Décoder le XML template - NODE.JS VERSION */ function decodeXMLTemplate(xmlTemplate) { if (!xmlTemplate) { throw new Error('Template XML manquant'); } // Si le template commence déjà par processed = cleanStrongTags(processed); // Autres nettoyages futurs... return processed; } // ============= SAUVEGARDE ============= /** * Sauvegarder l'article avec métadonnées source - ASYNC pour Node.js */ async function saveArticle(finalXML, generatedContent, finalElements, csvData, source) { await logSh('💾 Sauvegarde article...', 'INFO'); const articleData = { xmlContent: finalXML, generatedTexts: generatedContent, elementsGenerated: finalElements.length, originalElements: finalElements }; const storageConfig = { antiDetectionLevel: 'Selective_Enhancement', llmUsed: 'claude+openai+gemini+mistral', workflowVersion: '2.0-NodeJS', // 🔄 Mise à jour version source: source || 'node_server', // 🔄 Source par défaut enhancementTechniques: [ 'technical_terms_gpt4', 'transitions_gemini', 'personality_style_mistral' ] }; try { const articleStorage = await saveGeneratedArticleOrganic(articleData, csvData, storageConfig); await logSh(`✅ Article sauvé: ID ${articleStorage.articleId}`, 'INFO'); return articleStorage; } catch (storageError) { await logSh(`⚠️ Erreur sauvegarde: ${storageError.toString()}`, 'WARNING'); return null; // Non-bloquant } } // ============= RÉPONSE ============= /** * Construire la réponse finale du workflow - ASYNC pour logSh */ async function buildWorkflowResponse(finalXML, generatedContent, finalElements, csvData, validationReport, articleStorage, source) { const response = { success: true, source: source, xmlContent: finalXML, generatedTexts: generatedContent, elementsGenerated: finalElements.length, personality: csvData.personality?.nom || 'Unknown', csvData: { mc0: csvData.mc0, t0: csvData.t0, personality: csvData.personality?.nom }, timestamp: new Date().toISOString(), validationReport: validationReport, articleStorage: articleStorage, // NOUVELLES MÉTADONNÉES PHASE 2 antiDetectionLevel: 'Selective_Enhancement', llmsUsed: ['claude', 'openai', 'gemini', 'mistral'], enhancementApplied: true, workflowVersion: '2.0-NodeJS', // 🔄 Version mise à jour // STATS PERFORMANCE stats: { xmlLength: finalXML.length, contentPieces: Object.keys(generatedContent).length, wordCount: calculateTotalWordCount(generatedContent), validationStatus: validationReport.status } }; await logSh(`🔍 Response.stats: ${JSON.stringify(response.stats)}`, 'DEBUG'); return response; } // ============= HELPERS ============= /** * Calculer nombre total de mots - IDENTIQUE */ function calculateTotalWordCount(generatedContent) { let totalWords = 0; Object.values(generatedContent).forEach(content => { if (content && typeof content === 'string') { totalWords += content.trim().split(/\s+/).length; } }); return totalWords; } // ============= POINTS D'ENTRÉE SUPPLÉMENTAIRES ============= /** * Test du workflow principal - ASYNC pour Node.js */ async function testMainWorkflow() { try { const testData = { csvData: { mc0: 'plaque test nodejs', t0: 'Test workflow principal Node.js', personality: { nom: 'Marc', style: 'professionnel' }, tMinus1: 'parent test', mcPlus1: 'mot1,mot2,mot3,mot4', tPlus1: 'Titre1,Titre2,Titre3,Titre4' }, xmlTemplate: Buffer.from('|Test_Element{{T0}}|').toString('base64'), source: 'test_main_nodejs' }; const result = await handleFullWorkflow(testData); return result; } catch (error) { throw error; } finally { tracer.printSummary(); } } // 🔄 NODE.JS EXPORTS module.exports = { handleFullWorkflow, testMainWorkflow, prepareCSVData, decodeXMLTemplate, preprocessXML, saveArticle, buildWorkflowResponse, calculateTotalWordCount, launchLogViewer }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/test-manual.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: test-manual.js - ENTRY POINT MANUEL // Description: Test workflow ligne 2 Google Sheets // Usage: node test-manual.js // ======================================== require('./polyfills/fetch.cjs'); require('dotenv').config(); const { handleFullWorkflow } = require('./Main'); const { logSh } = require('./ErrorReporting'); /** * TEST MANUEL LIGNE 2 */ async function testWorkflowLigne2() { logSh('🚀 === DÉMARRAGE TEST MANUEL LIGNE 2 ===', 'INFO'); // Using logSh instead of console.log const startTime = Date.now(); try { // DONNÉES DE TEST POUR LIGNE 2 const testData = { rowNumber: 2, // Ligne 2 Google Sheets source: 'test_manual_nodejs' }; logSh('📊 Configuration test:', 'INFO'); // Using logSh instead of console.log logSh(` • Ligne: ${testData.rowNumber}`, 'INFO'); // Using logSh instead of console.log logSh(` • Source: ${testData.source}`, 'INFO'); // Using logSh instead of console.log logSh(` • Timestamp: ${new Date().toISOString()}`, 'INFO'); // Using logSh instead of console.log // LANCER LE WORKFLOW logSh('\n🎯 Lancement workflow principal...', 'INFO'); // Using logSh instead of console.log const result = await handleFullWorkflow(testData); // AFFICHER RÉSULTATS const duration = Date.now() - startTime; logSh('\n🏆 === WORKFLOW TERMINÉ AVEC SUCCÈS ===', 'INFO'); // Using logSh instead of console.log logSh(`⏱️ Durée: ${Math.round(duration/1000)}s`, 'INFO'); // Using logSh instead of console.log logSh(`📊 Status: ${result.success ? '✅ SUCCESS' : '❌ ERROR'}`, 'INFO'); // Using logSh instead of console.log if (result.success) { logSh(`📝 Éléments générés: ${result.elementsGenerated}`, 'INFO'); // Using logSh instead of console.log logSh(`👤 Personnalité: ${result.personality}`, 'INFO'); // Using logSh instead of console.log logSh(`🎯 MC0: ${result.csvData?.mc0 || 'N/A'}`, 'INFO'); // Using logSh instead of console.log logSh(`📄 XML length: ${result.stats?.xmlLength || 'N/A'} chars`, 'INFO'); // Using logSh instead of console.log logSh(`🔤 Mots total: ${result.stats?.wordCount || 'N/A'}`, 'INFO'); // Using logSh instead of console.log logSh(`🧠 LLMs utilisés: ${result.llmsUsed?.join(', ') || 'N/A'}`, 'INFO'); // Using logSh instead of console.log if (result.articleStorage) { logSh(`💾 Article sauvé: ID ${result.articleStorage.articleId}`, 'INFO'); // Using logSh instead of console.log } } logSh('\n📋 Résultat complet:', 'DEBUG'); // Using logSh instead of console.log logSh(JSON.stringify(result, null, 2), 'DEBUG'); // Using logSh instead of console.log return result; } catch (error) { const duration = Date.now() - startTime; logSh('\n❌ === ERREUR WORKFLOW ===', 'ERROR'); // Using logSh instead of console.error logSh(`❌ Message: ${error.message}`, 'ERROR'); // Using logSh instead of console.error logSh(`❌ Durée avant échec: ${Math.round(duration/1000)}s`, 'ERROR'); // Using logSh instead of console.error if (process.env.NODE_ENV === 'development') { logSh(`❌ Stack: ${error.stack}`, 'ERROR'); // Using logSh instead of console.error } // Afficher conseils de debug logSh('\n🔧 CONSEILS DE DEBUG:', 'INFO'); // Using logSh instead of console.log logSh('1. Vérifiez vos variables d\'environnement (.env)', 'INFO'); // Using logSh instead of console.log logSh('2. Vérifiez la connexion Google Sheets', 'INFO'); // Using logSh instead of console.log logSh('3. Vérifiez les API keys LLM', 'INFO'); // Using logSh instead of console.log logSh('4. Regardez les logs détaillés dans ./logs/', 'INFO'); // Using logSh instead of console.log process.exit(1); } } /** * VÉRIFICATIONS PRÉALABLES */ function checkEnvironment() { logSh('🔍 Vérification environnement...', 'INFO'); // Using logSh instead of console.log const required = [ 'GOOGLE_SHEETS_ID', 'OPENAI_API_KEY' ]; const missing = required.filter(key => !process.env[key]); if (missing.length > 0) { logSh('❌ Variables d\'environnement manquantes:', 'ERROR'); // Using logSh instead of console.error missing.forEach(key => logSh(` • ${key}`, 'ERROR')); // Using logSh instead of console.error logSh('\n💡 Créez un fichier .env avec ces variables', 'ERROR'); // Using logSh instead of console.error process.exit(1); } logSh('✅ Variables d\'environnement OK', 'INFO'); // Using logSh instead of console.log // Info sur les variables configurées logSh('📋 Configuration détectée:', 'INFO'); // Using logSh instead of console.log logSh(` • Google Sheets ID: ${process.env.GOOGLE_SHEETS_ID}`, 'INFO'); // Using logSh instead of console.log logSh(` • OpenAI: ${process.env.OPENAI_API_KEY ? '✅ Configuré' : '❌ Manquant'}`, 'INFO'); // Using logSh instead of console.log logSh(` • Claude: ${process.env.CLAUDE_API_KEY ? '✅ Configuré' : '⚠️ Optionnel'}`, 'INFO'); // Using logSh instead of console.log logSh(` • Gemini: ${process.env.GEMINI_API_KEY ? '✅ Configuré' : '⚠️ Optionnel'}`, 'INFO'); // Using logSh instead of console.log } /** * POINT D'ENTRÉE PRINCIPAL */ async function main() { try { // Vérifications préalables checkEnvironment(); // Test workflow await testWorkflowLigne2(); logSh('\n🎉 Test manuel terminé avec succès !', 'INFO'); // Using logSh instead of console.log process.exit(0); } catch (error) { logSh('\n💥 Erreur fatale: ' + error.message, 'ERROR'); // Using logSh instead of console.error process.exit(1); } } // Lancer si exécuté directement if (require.main === module) { main(); } module.exports = { testWorkflowLigne2 }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/ManualTrigger.js │ └────────────────────────────────────────────────────────────────────┘ */ const { logSh } = require('./ErrorReporting'); // Using unified logSh from ErrorReporting /** * 🚀 TRIGGER MANUEL - Lit ligne 2 et lance le workflow * Exécute cette fonction depuis l'éditeur Apps Script */ function runWorkflowLigne(numeroLigne = 2) { cleanLogSheet(); // Nettoie les logs pour ce test try { logSh('🎬 >>> DÉMARRAGE WORKFLOW MANUEL <<<', 'INFO'); // 1. LIRE AUTOMATIQUEMENT LA LIGNE INDIQUÉ const csvData = readCSVDataFromRow(numeroLigne); logSh(`✅ Données lues - MC0: ${csvData.mc0}`, 'INFO'); logSh(`✅ Titre: ${csvData.t0}`, 'INFO'); logSh(`✅ Personnalité: ${csvData.personality.nom}`, 'INFO'); // 2. XML TEMPLATE SIMPLE POUR TEST (ou lit depuis Digital Ocean si configuré) const xmlTemplate = getXMLTemplateForTest(csvData); logSh(`✅ XML template: ${xmlTemplate.length} caractères`, 'INFO'); // 3. 🎯 LANCER LE WORKFLOW PRINCIPAL const workflowData = { csvData: csvData, xmlTemplate: Utilities.base64Encode(xmlTemplate), source: 'manuel_ligne2' }; const result = handleFullWorkflow(workflowData); logSh('🏆 === WORKFLOW MANUEL TERMINÉ ===', 'INFO'); // ← EXTRAIRE LES VRAIES DONNÉES let actualData; if (result && result.getContentText) { // C'est un ContentService, extraire le JSON actualData = JSON.parse(result.getContentText()); } else { actualData = result; } logSh(`Type result: ${typeof result}`, 'DEBUG'); logSh(`Result keys: ${Object.keys(result || {})}`, 'DEBUG'); logSh(`ActualData keys: ${Object.keys(actualData || {})}`, 'DEBUG'); logSh(`ActualData: ${JSON.stringify(actualData)}`, 'DEBUG'); if (actualData && actualData.stats) { logSh(`📊 Éléments générés: ${actualData.stats.contentPieces}`, 'INFO'); logSh(`📝 Nombre de mots: ${actualData.stats.wordCount}`, 'INFO'); } else { logSh('⚠️ Format résultat inattendu', 'WARNING'); logSh('ActualData: ' + JSON.stringify(actualData, null, 2), 'DEBUG'); // Using logSh instead of console.log } return actualData; } catch (error) { logSh(`❌ ERREUR WORKFLOW MANUEL: ${error.toString()}`, 'ERROR'); logSh(`Stack: ${error.stack}`, 'ERROR'); throw error; } } /** * HELPER - Lire CSV depuis une ligne spécifique */ function readCSVDataFromRow(rowNumber) { const sheetId = '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c'; const spreadsheet = SpreadsheetApp.openById(sheetId); const articlesSheet = spreadsheet.getSheetByName('instructions'); // Lire la ligne complète (colonnes A à H) const range = articlesSheet.getRange(rowNumber, 1, 1, 9); const [slug, t0, mc0, tMinus1, lMinus1, mcPlus1, tPlus1, lPlus1, xmlFileName] = range.getValues()[0]; logSh(`📖 Lecture ligne ${rowNumber}: ${slug}`, 'DEBUG'); // Récupérer personnalités et sélectionner automatiquement const personalitiesSheet = spreadsheet.getSheetByName('Personnalites'); const personalities = getPersonalities(personalitiesSheet); const selectedPersonality = selectPersonalityWithAI(mc0, t0, personalities); return { rowNumber: rowNumber, slug: slug || 'test-slug', t0: t0 || 'Titre par défaut', mc0: mc0 || 'mot-clé test', tMinus1: tMinus1 || 'parent', lMinus1: lMinus1 || '/parent', mcPlus1: mcPlus1 || 'mot1,mot2,mot3,mot4', tPlus1: tPlus1 || 'Titre1,Titre2,Titre3,Titre4', lPlus1: lPlus1 || '/lien1,/lien2,/lien3,/lien4', personality: selectedPersonality, xmlFileName: xmlFileName ? xmlFileName.toString().trim() : null }; } /** * HELPER - XML Template simple pour test (ou depuis Digital Ocean) */ function getXMLTemplateForTest(csvData) { logSh("csvData.xmlFileName: " + csvData.xmlFileName, 'DEBUG'); // Using logSh instead of console.log if (csvData.xmlFileName) { logSh("Tentative Digital Ocean...", 'INFO'); // Using logSh instead of console.log try { return fetchXMLFromDigitalOceanSimple(csvData.xmlFileName); } catch (error) { // ← ENLÈVE LE CATCH SILENCIEUX logSh("Erreur DO: " + error.toString(), 'WARNING'); // Using logSh instead of console.log logSh(`❌ ERREUR DO DÉTAILLÉE: ${error.toString()}`, 'ERROR'); // Continue sans Digital Ocean } } logSh("❌ FATAL: Aucun template XML disponible", 'ERROR'); throw new Error("FATAL: Template XML indisponible (Digital Ocean inaccessible + pas de fallback) - arrêt du workflow"); } /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/trace-wrap.js │ └────────────────────────────────────────────────────────────────────┘ */ // lib/trace-wrap.js const { tracer } = require('./trace.js'); const traced = (name, fn, attrs) => (...args) => tracer.run(name, () => fn(...args), attrs); module.exports = { traced }; /* ┌────────────────────────────────────────────────────────────────────┐ │ File: lib/Utils.js │ └────────────────────────────────────────────────────────────────────┘ */ // ======================================== // FICHIER: utils.js - Conversion Node.js // Description: Utilitaires génériques pour le workflow // ======================================== // Import du système de logging (assumant que logSh est disponible globalement) // const { logSh } = require('./logging'); // À décommenter si logSh est dans un module séparé /** * Créer une réponse de succès standardisée * @param {Object} data - Données à retourner * @returns {Object} Réponse formatée pour Express/HTTP */ function createSuccessResponse(data) { return { success: true, data: data, timestamp: new Date().toISOString() }; } /** * Créer une réponse d'erreur standardisée * @param {string|Error} error - Message d'erreur ou objet Error * @returns {Object} Réponse d'erreur formatée */ function createErrorResponse(error) { const errorMessage = error instanceof Error ? error.message : error.toString(); return { success: false, error: errorMessage, timestamp: new Date().toISOString(), stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined }; } /** * Middleware Express pour envoyer des réponses standardisées * Usage: res.success(data) ou res.error(error) */ function responseMiddleware(req, res, next) { // Méthode pour réponse de succès res.success = (data, statusCode = 200) => { res.status(statusCode).json(createSuccessResponse(data)); }; // Méthode pour réponse d'erreur res.error = (error, statusCode = 500) => { res.status(statusCode).json(createErrorResponse(error)); }; next(); } /** * HELPER : Nettoyer les instructions FAQ * Remplace les variables et nettoie le HTML * @param {string} instructions - Instructions à nettoyer * @param {Object} csvData - Données CSV pour remplacement variables * @returns {string} Instructions nettoyées */ function cleanFAQInstructions(instructions, csvData) { if (!instructions || !csvData) { return instructions || ''; } let clean = instructions.toString(); try { // Remplacer variables simples clean = clean.replace(/\{\{MC0\}\}/g, csvData.mc0 || ''); clean = clean.replace(/\{\{T0\}\}/g, csvData.t0 || ''); // Variables multiples si nécessaire if (csvData.mcPlus1) { const mcPlus1 = csvData.mcPlus1.split(',').map(s => s.trim()); for (let i = 1; i <= 6; i++) { const mcValue = mcPlus1[i-1] || `[MC+1_${i} non défini]`; clean = clean.replace(new RegExp(`\\{\\{MC\\+1_${i}\\}\\}`, 'g'), mcValue); } } // Variables T+1 et L+1 si disponibles if (csvData.tPlus1) { const tPlus1 = csvData.tPlus1.split(',').map(s => s.trim()); for (let i = 1; i <= 6; i++) { const tValue = tPlus1[i-1] || `[T+1_${i} non défini]`; clean = clean.replace(new RegExp(`\\{\\{T\\+1_${i}\\}\\}`, 'g'), tValue); } } if (csvData.lPlus1) { const lPlus1 = csvData.lPlus1.split(',').map(s => s.trim()); for (let i = 1; i <= 6; i++) { const lValue = lPlus1[i-1] || `[L+1_${i} non défini]`; clean = clean.replace(new RegExp(`\\{\\{L\\+1_${i}\\}\\}`, 'g'), lValue); } } // Nettoyer HTML clean = clean.replace(/<\/?[^>]+>/g, ''); // Nettoyer espaces en trop clean = clean.replace(/\s+/g, ' ').trim(); } catch (error) { if (typeof logSh === 'function') { logSh(`⚠️ Erreur nettoyage instructions FAQ: ${error.toString()}`, 'WARNING'); } // Retourner au moins la version partiellement nettoyée } return clean; } /** * Utilitaire pour attendre un délai (remplace Utilities.sleep de Google Apps Script) * @param {number} ms - Millisecondes à attendre * @returns {Promise} Promise qui se résout après le délai */ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Utilitaire pour encoder en base64 * @param {string} text - Texte à encoder * @returns {string} Texte encodé en base64 */ function base64Encode(text) { return Buffer.from(text, 'utf8').toString('base64'); } /** * Utilitaire pour décoder du base64 * @param {string} base64Text - Texte base64 à décoder * @returns {string} Texte décodé */ function base64Decode(base64Text) { return Buffer.from(base64Text, 'base64').toString('utf8'); } /** * Valider et nettoyer un slug/filename * @param {string} slug - Slug à nettoyer * @returns {string} Slug nettoyé */ function cleanSlug(slug) { if (!slug) return ''; return slug .toString() .toLowerCase() .replace(/[^a-z0-9\-_]/g, '-') // Remplacer caractères spéciaux par - .replace(/-+/g, '-') // Éviter doubles tirets .replace(/^-+|-+$/g, ''); // Enlever tirets début/fin } /** * Compter les mots dans un texte * @param {string} text - Texte à analyser * @returns {number} Nombre de mots */ function countWords(text) { if (!text || typeof text !== 'string') return 0; return text .trim() .replace(/\s+/g, ' ') // Normaliser espaces .split(' ') .filter(word => word.length > 0) .length; } /** * Formater une durée en millisecondes en format lisible * @param {number} ms - Durée en millisecondes * @returns {string} Durée formatée (ex: "2.3s" ou "450ms") */ function formatDuration(ms) { if (ms < 1000) { return `${ms}ms`; } else if (ms < 60000) { return `${(ms / 1000).toFixed(1)}s`; } else { const minutes = Math.floor(ms / 60000); const seconds = ((ms % 60000) / 1000).toFixed(1); return `${minutes}m ${seconds}s`; } } /** * Utilitaire pour retry automatique d'une fonction * @param {Function} fn - Fonction à exécuter avec retry * @param {number} maxRetries - Nombre maximum de tentatives * @param {number} delay - Délai entre tentatives (ms) * @returns {Promise} Résultat de la fonction ou erreur finale */ async function withRetry(fn, maxRetries = 3, delay = 1000) { let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (typeof logSh === 'function') { logSh(`⚠️ Tentative ${attempt}/${maxRetries} échouée: ${error.toString()}`, 'WARNING'); } if (attempt < maxRetries) { await sleep(delay * attempt); // Exponential backoff } } } throw lastError; } /** * Validation basique d'email * @param {string} email - Email à valider * @returns {boolean} True si email valide */ function isValidEmail(email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } /** * Générer un ID unique simple * @returns {string} ID unique basé sur timestamp + random */ function generateId() { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Truncate un texte à une longueur donnée * @param {string} text - Texte à tronquer * @param {number} maxLength - Longueur maximale * @param {string} suffix - Suffixe à ajouter si tronqué (défaut: '...') * @returns {string} Texte tronqué */ function truncate(text, maxLength, suffix = '...') { if (!text || text.length <= maxLength) { return text; } return text.substring(0, maxLength - suffix.length) + suffix; } // ============= EXPORTS ============= module.exports = { createSuccessResponse, createErrorResponse, responseMiddleware, cleanFAQInstructions, sleep, base64Encode, base64Decode, cleanSlug, countWords, formatDuration, withRetry, isValidEmail, generateId, truncate };