From b2fe9e0b7b3ca390e173d7ad3f33facdebe24dd8 Mon Sep 17 00:00:00 2001 From: StillHammer Date: Wed, 8 Oct 2025 14:52:19 +0800 Subject: [PATCH] Fix workflow production avec XML Digital Ocean et format Google Sheets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrections majeures: - Digital Ocean: Récupération réelle XML depuis /wp-content/XML/ (86k chars au lieu de mock 1k) - Nettoyage tags: Suppression dans extractElements() pour éviter parsing errors - Doublons résilients: Tolérance doublons XML avec validation tags uniques - Hiérarchie complète: StepExecutor génère 36 éléments depuis hierarchy.originalElement.name - Format Google Sheets: Adaptation colonnes selon useVersionedSheet (17 legacy vs 21 versioned) - Range Google Sheets: Force A1 avec INSERT_ROWS pour éviter décalage U:AO - xmlTemplate optimisé: Exclusion du JSON metadata pour limite 50k chars Résultat: 2151 mots, 36 éléments, sauvegarde correcte A-Q 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/ArticleStorage.js | 92 ++++++++++++++++++++---------- lib/BrainConfig.js | 24 ++++++-- lib/ElementExtraction.js | 9 ++- lib/Main.js | 4 +- lib/MissingKeywords.js | 28 +++++++-- lib/StepExecutor.js | 70 +++++++++++++++++++---- lib/batch/DigitalOceanTemplates.js | 68 +++++++++++++--------- 7 files changed, 214 insertions(+), 81 deletions(-) diff --git a/lib/ArticleStorage.js b/lib/ArticleStorage.js index 45cec6e..339efd0 100644 --- a/lib/ArticleStorage.js +++ b/lib/ArticleStorage.js @@ -264,47 +264,81 @@ async function saveGeneratedArticleOrganic(articleData, csvData, config = {}) { versionHistory: config.versionHistory || null }; - // Préparer la ligne de données avec versioning - 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, - // 🆕 Colonnes de versioning - metadata.version, - metadata.stage, - metadata.stageDescription, - metadata.parentArticleId || '', - '', '', '', '', // Colonnes de scores détecteurs (réservées) - JSON.stringify({ - csvData: csvData, - config: config, - stats: metadata, - versionHistory: metadata.versionHistory // Inclure l'historique - }) - ]; + // Préparer la ligne de données selon le format de la sheet + let row; + + if (config.useVersionedSheet) { + // Format VERSIONED (21 colonnes) : avec version, stage, stageDescription, parentArticleId + row = [ + metadata.timestamp, + metadata.slug, + metadata.mc0, + metadata.t0, + metadata.personality, + metadata.antiDetectionLevel, + compiledText, + metadata.textLength, + metadata.wordCount, + metadata.elementsCount, + metadata.llmUsed, + metadata.validationStatus, + metadata.version, // Colonne M + metadata.stage, // Colonne N + metadata.stageDescription, // Colonne O + metadata.parentArticleId || '', // Colonne P + '', '', '', '', // Colonnes Q,R,S,T : scores détecteurs (réservées) + JSON.stringify({ // Colonne U + csvData: { ...csvData, xmlTemplate: undefined, xmlFileName: csvData.xmlFileName }, + config: config, + stats: metadata, + versionHistory: metadata.versionHistory + }) + ]; + } else { + // Format LEGACY (17 colonnes) : sans version/stage, scores détecteurs à la place + row = [ + metadata.timestamp, + metadata.slug, + metadata.mc0, + metadata.t0, + metadata.personality, + metadata.antiDetectionLevel, + compiledText, + metadata.textLength, + metadata.wordCount, + metadata.elementsCount, + metadata.llmUsed, + metadata.validationStatus, + '', '', '', '', // Colonnes M,N,O,P : scores détecteurs (GPTZero, Originality, CopyLeaks, HumanQuality) + JSON.stringify({ // Colonne Q + csvData: { ...csvData, xmlTemplate: undefined, xmlFileName: csvData.xmlFileName }, + 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 dans la bonne sheet - const targetRange = config.useVersionedSheet ? 'Generated_Articles_Versioned!A:U' : 'Generated_Articles!A:U'; - await sheets.spreadsheets.values.append({ + // Forcer le range à A1 pour éviter le décalage horizontal + const targetRange = config.useVersionedSheet ? 'Generated_Articles_Versioned!A1' : 'Generated_Articles!A1'; + + logSh(`🔍 DEBUG APPEND: sheetId=${SHEET_CONFIG.sheetId}, range=${targetRange}, rowLength=${row.length}`, 'INFO'); + logSh(`🔍 DEBUG ROW PREVIEW: [${row.slice(0, 5).map(c => typeof c === 'string' ? c.substring(0, 50) : c).join(', ')}...]`, 'INFO'); + + const appendResult = await sheets.spreadsheets.values.append({ spreadsheetId: SHEET_CONFIG.sheetId, range: targetRange, valueInputOption: 'USER_ENTERED', + insertDataOption: 'INSERT_ROWS', // Force l'insertion d'une nouvelle ligne resource: { values: [row] } }); + + logSh(`✅ APPEND SUCCESS: ${appendResult.status} - Updated ${appendResult.data.updates?.updatedCells || 0} cells`, 'INFO'); // Récupérer le numéro de ligne pour l'ID article const targetRangeForId = config.useVersionedSheet ? 'Generated_Articles_Versioned!A:A' : 'Generated_Articles!A:A'; diff --git a/lib/BrainConfig.js b/lib/BrainConfig.js index 7573a2c..ad6328b 100644 --- a/lib/BrainConfig.js +++ b/lib/BrainConfig.js @@ -10,6 +10,7 @@ const path = require('path'); // Import de la fonction logSh (assumant qu'elle existe dans votre projet Node.js) const { logSh } = require('./ErrorReporting'); +const { DigitalOceanTemplates } = require('./batch/DigitalOceanTemplates'); // Configuration const CONFIG = { @@ -117,12 +118,25 @@ async function readInstructionsData(rowNumber = 2) { 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 + + // Si c'est un nom de fichier, le récupérer depuis Digital Ocean 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 + logSh(`🔧 XML filename detected (${xmlTemplateValue}), fetching from Digital Ocean`, 'INFO'); + xmlFileName = xmlTemplateValue; + + // Récupérer le template depuis Digital Ocean + try { + const doTemplates = new DigitalOceanTemplates(); + xmlTemplate = await doTemplates.getTemplate(xmlFileName); + logSh(`✅ Template ${xmlFileName} récupéré depuis Digital Ocean (${xmlTemplate?.length || 0} chars)`, 'INFO'); + + if (!xmlTemplate) { + throw new Error('Template vide récupéré'); + } + } catch (error) { + logSh(`⚠️ Erreur récupération ${xmlFileName} depuis DO: ${error.message}. Fallback template par défaut.`, 'WARNING'); + xmlTemplate = createDefaultXMLTemplate(); + } } return { diff --git a/lib/ElementExtraction.js b/lib/ElementExtraction.js index ab2a888..0ec1ee4 100644 --- a/lib/ElementExtraction.js +++ b/lib/ElementExtraction.js @@ -26,9 +26,12 @@ async function extractElements(xmlTemplate, csvData) { // 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]; - + + let tagName = nameMatch ? nameMatch[1].trim() : fullMatch.split('{')[0]; + + // NETTOYAGE: Enlever , du nom du tag + tagName = tagName.replace(/<\/?strong>/g, ''); + // TAG PUR (sans variables) const pureTag = `|${tagName}|`; diff --git a/lib/Main.js b/lib/Main.js index a0df7c5..c6cf79b 100644 --- a/lib/Main.js +++ b/lib/Main.js @@ -331,7 +331,9 @@ async function handleModularWorkflow(config = {}) { // 🆕 SAUVEGARDE ÉTAPE 1: Génération initiale let parentArticleId = null; let versionHistory = []; - + + logSh(`🔍 DEBUG: saveIntermediateSteps = ${saveIntermediateSteps}`, 'INFO'); + if (saveIntermediateSteps) { logSh(`💾 SAUVEGARDE v1.0: Génération initiale`, 'INFO'); diff --git a/lib/MissingKeywords.js b/lib/MissingKeywords.js index 66594c2..7a5ec0b 100644 --- a/lib/MissingKeywords.js +++ b/lib/MissingKeywords.js @@ -201,13 +201,29 @@ function parseMissingKeywordsResponse(response, missingElements) { 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`); + // VALIDATION: Vérifier qu'on a au moins récupéré des résultats (tolérer doublons) + const uniqueNames = [...new Set(missingElements.map(e => e.name))]; + const parsedCount = Object.keys(results).length; + + if (parsedCount === 0) { + logSh(`❌ FATAL: Aucun mot-clé parsé`, 'ERROR'); + throw new Error(`FATAL: Parsing mots-clés échoué complètement - arrêt du workflow`); } - + + // Warning si doublons détectés (mais on continue) + if (missingElements.length > uniqueNames.length) { + const doublonsCount = missingElements.length - uniqueNames.length; + logSh(`⚠️ ${doublonsCount} doublons détectés dans les tags XML (${uniqueNames.length} tags uniques)`, 'WARNING'); + } + + // Vérifier qu'on a au moins autant de résultats que de tags uniques + if (parsedCount < uniqueNames.length) { + const manquants = uniqueNames.length - parsedCount; + logSh(`❌ FATAL: Parsing incomplet - ${manquants}/${uniqueNames.length} tags uniques non parsés`, 'ERROR'); + throw new Error(`FATAL: Parsing mots-clés incomplet (${manquants}/${uniqueNames.length} manquants) - arrêt du workflow`); + } + + logSh(`✅ ${parsedCount} mots-clés parsés pour ${uniqueNames.length} tags uniques (${missingElements.length} éléments total)`, 'INFO'); return results; } diff --git a/lib/StepExecutor.js b/lib/StepExecutor.js index d0b7230..177a335 100644 --- a/lib/StepExecutor.js +++ b/lib/StepExecutor.js @@ -91,7 +91,58 @@ class StepExecutor { // ======================================== // EXÉCUTEURS SPÉCIFIQUES // ======================================== - + + /** + * Construire la structure de contenu depuis la hiérarchie réelle + */ + buildContentStructureFromHierarchy(inputData, hierarchy) { + const contentStructure = {}; + + // Si hiérarchie disponible, l'utiliser + if (hierarchy && Object.keys(hierarchy).length > 0) { + logSh(`🔍 Hiérarchie debug: ${Object.keys(hierarchy).length} sections`, 'DEBUG'); + logSh(`🔍 Première section sample: ${JSON.stringify(Object.values(hierarchy)[0]).substring(0, 200)}`, 'DEBUG'); + + Object.entries(hierarchy).forEach(([path, section]) => { + // Générer pour le titre si présent + if (section.title && section.title.originalElement) { + const tag = section.title.originalElement.name; + const instruction = section.title.instructions || section.title.originalElement.instructions || `Rédige un titre pour ${inputData.mc0}`; + contentStructure[tag] = instruction; + } + + // Générer pour le texte si présent + if (section.text && section.text.originalElement) { + const tag = section.text.originalElement.name; + const instruction = section.text.instructions || section.text.originalElement.instructions || `Rédige du contenu sur ${inputData.mc0}`; + contentStructure[tag] = instruction; + } + + // Générer pour les questions FAQ si présentes + if (section.questions && section.questions.length > 0) { + section.questions.forEach(q => { + if (q.originalElement) { + const tag = q.originalElement.name; + const instruction = q.instructions || q.originalElement.instructions || `Rédige une question/réponse FAQ sur ${inputData.mc0}`; + contentStructure[tag] = instruction; + } + }); + } + }); + + logSh(`🏗️ Structure depuis hiérarchie: ${Object.keys(contentStructure).length} éléments`, 'DEBUG'); + } else { + // Fallback: structure générique si pas de hiérarchie + logSh(`⚠️ Pas de hiérarchie, utilisation structure générique`, 'WARNING'); + contentStructure['Titre_H1'] = `Rédige un titre H1 accrocheur et optimisé SEO sur ${inputData.mc0}`; + contentStructure['Introduction'] = `Rédige une introduction engageante qui présente ${inputData.mc0}`; + contentStructure['Contenu_Principal'] = `Développe le contenu principal détaillé sur ${inputData.mc0} avec des informations utiles et techniques`; + contentStructure['Conclusion'] = `Rédige une conclusion percutante qui encourage à l'action pour ${inputData.mc0}`; + } + + return contentStructure; + } + /** * Execute Initial Generation */ @@ -100,19 +151,18 @@ class StepExecutor { const { InitialGenerationLayer } = require('./generation/InitialGeneration'); logSh('🎯 Démarrage Génération Initiale', 'DEBUG'); - + const config = { temperature: options.temperature || 0.7, maxTokens: options.maxTokens || 4000 }; - - // Créer la structure de contenu à générer - const contentStructure = { - 'Titre_H1': `Rédige un titre H1 accrocheur et optimisé SEO sur ${inputData.mc0}`, - 'Introduction': `Rédige une introduction engageante qui présente ${inputData.mc0}`, - 'Contenu_Principal': `Développe le contenu principal détaillé sur ${inputData.mc0} avec des informations utiles et techniques`, - 'Conclusion': `Rédige une conclusion percutante qui encourage à l'action pour ${inputData.mc0}` - }; + + // Créer la structure de contenu à générer depuis la hiérarchie réelle + // La hiérarchie peut être dans inputData.hierarchy OU options.hierarchy + const hierarchy = options.hierarchy || inputData.hierarchy; + const contentStructure = this.buildContentStructureFromHierarchy(inputData, hierarchy); + + logSh(`📊 Structure construite: ${Object.keys(contentStructure).length} éléments depuis hiérarchie`, 'DEBUG'); const initialGenerator = new InitialGenerationLayer(); const result = await initialGenerator.apply(contentStructure, { diff --git a/lib/batch/DigitalOceanTemplates.js b/lib/batch/DigitalOceanTemplates.js index e55a0b0..ce8c88f 100644 --- a/lib/batch/DigitalOceanTemplates.js +++ b/lib/batch/DigitalOceanTemplates.js @@ -3,11 +3,13 @@ // Responsabilité: Récupération et cache des templates XML depuis DigitalOcean Spaces // ======================================== +require('dotenv').config(); const { logSh } = require('../ErrorReporting'); const { tracer } = require('../trace'); const fs = require('fs').promises; const path = require('path'); const axios = require('axios'); +const AWS = require('aws-sdk'); /** * DIGITAL OCEAN TEMPLATES MANAGER @@ -17,12 +19,22 @@ class DigitalOceanTemplates { constructor() { this.cacheDir = path.join(__dirname, '../../cache/templates'); + + // Extraire bucket du endpoint si présent (ex: https://autocollant.fra1.digitaloceanspaces.com) + let endpoint = process.env.DO_ENDPOINT || process.env.DO_SPACES_ENDPOINT || 'https://fra1.digitaloceanspaces.com'; + let bucket = process.env.DO_BUCKET_NAME || process.env.DO_SPACES_BUCKET || 'autocollant'; + + // Si endpoint contient le bucket, le retirer + if (endpoint.includes(`${bucket}.`)) { + endpoint = endpoint.replace(`${bucket}.`, ''); + } + this.config = { - endpoint: process.env.DO_SPACES_ENDPOINT || 'https://fra1.digitaloceanspaces.com', - bucket: process.env.DO_SPACES_BUCKET || 'autocollant', - region: process.env.DO_SPACES_REGION || 'fra1', - accessKey: process.env.DO_SPACES_KEY, - secretKey: process.env.DO_SPACES_SECRET, + endpoint: endpoint, + bucket: bucket, + region: process.env.DO_REGION || process.env.DO_SPACES_REGION || 'fra1', + accessKey: process.env.DO_ACCESS_KEY_ID || process.env.DO_SPACES_KEY, + secretKey: process.env.DO_SECRET_ACCESS_KEY || process.env.DO_SPACES_SECRET, timeout: 10000 // 10 secondes }; @@ -81,7 +93,7 @@ class DigitalOceanTemplates { * Récupère un template XML (avec cache et fallback) */ async getTemplate(filename) { - return tracer.run('DigitalOceanTemplates.getTemplate', { filename }, async () => { + return tracer.run('DigitalOceanTemplates.getTemplate', async () => { if (!filename) { throw new Error('Nom de fichier template requis'); } @@ -141,35 +153,37 @@ class DigitalOceanTemplates { * Récupère depuis Digital Ocean Spaces */ async fetchFromDigitalOcean(filename) { - return tracer.run('DigitalOceanTemplates.fetchFromDigitalOcean', { filename }, async () => { - const url = `${this.config.endpoint}/${this.config.bucket}/templates/${filename}`; + return tracer.run('DigitalOceanTemplates.fetchFromDigitalOcean', async () => { + const fileKey = `wp-content/XML/${filename}`; - logSh(`🌊 Récupération DO: ${url}`, 'DEBUG'); + logSh(`🌊 Récupération DO avec authentification S3: ${fileKey}`, 'DEBUG'); try { - // Utiliser une requête simple sans authentification S3 complexe - // Digital Ocean Spaces peut être configuré pour accès public aux templates - const response = await axios.get(url, { - timeout: this.config.timeout, - responseType: 'text', - headers: { - 'Accept': 'application/xml, text/xml, text/plain' - } + // Configuration S3 pour Digital Ocean Spaces + const s3 = new AWS.S3({ + endpoint: this.config.endpoint, + accessKeyId: this.config.accessKey, + secretAccessKey: this.config.secretKey, + region: this.config.region, + s3ForcePathStyle: false, + signatureVersion: 'v4' }); - if (response.status === 200 && response.data) { - logSh(`✅ Template ${filename} récupéré (${response.data.length} chars)`, 'DEBUG'); - return response.data; - } + const params = { + Bucket: this.config.bucket, + Key: fileKey + }; - throw new Error(`Réponse invalide: ${response.status}`); + logSh(`🔑 S3 getObject: bucket=${this.config.bucket}, key=${fileKey}`, 'DEBUG'); + + const data = await s3.getObject(params).promise(); + const template = data.Body.toString('utf-8'); + + logSh(`✅ Template ${filename} récupéré depuis DO (${template.length} chars)`, 'INFO'); + return template; } catch (error) { - if (error.response) { - logSh(`❌ Digital Ocean error ${error.response.status}: ${error.response.statusText}`, 'WARNING'); - } else { - logSh(`❌ Digital Ocean network error: ${error.message}`, 'WARNING'); - } + logSh(`❌ Digital Ocean S3 error: ${error.message} (code: ${error.code})`, 'WARNING'); throw error; } });