Fix workflow production avec XML Digital Ocean et format Google Sheets

Corrections majeures:
- Digital Ocean: Récupération réelle XML depuis /wp-content/XML/ (86k chars au lieu de mock 1k)
- Nettoyage tags: Suppression <strong> 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 <noreply@anthropic.com>
This commit is contained in:
StillHammer 2025-10-08 14:52:19 +08:00
parent f51c4095f6
commit b2fe9e0b7b
7 changed files with 214 additions and 81 deletions

View File

@ -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';

View File

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

View File

@ -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 <strong>, </strong> du nom du tag
tagName = tagName.replace(/<\/?strong>/g, '');
// TAG PUR (sans variables)
const pureTag = `|${tagName}|`;

View File

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

View File

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

View File

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

View File

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