// ======================================== // 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(); // 🔧 FIX: Gérer le cas où elements est null/undefined if (!elements) { logSh('⚠️ Elements null, utilisation compilation simple', 'DEBUG'); // Compilation simple : tout le contenu dans l'ordre des clés Object.keys(generatedTexts).forEach(tag => { sections.push({ type: 'standalone_content', content: generatedTexts[tag], tag: tag }); }); return sections; } // 1. ANALYSER l'ordre original des éléments const originalOrder = elements.map(el => el.originalTag); 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(); // 🆕 Choisir la sheet selon le flag useVersionedSheet const targetSheetName = config.useVersionedSheet ? 'Generated_Articles_Versioned' : 'Generated_Articles'; let articlesSheet = await getOrCreateSheet(sheets, targetSheetName); // ===== 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', // 🆕 Métadonnées de versioning version: config.version || '1.0', stage: config.stage || 'final_version', stageDescription: config.stageDescription || 'Version finale', parentArticleId: config.parentArticleId || null, 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 }) ]; // 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({ spreadsheetId: SHEET_CONFIG.sheetId, range: targetRange, valueInputOption: 'USER_ENTERED', resource: { values: [row] } }); // 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'; const response = await sheets.spreadsheets.values.get({ spreadsheetId: SHEET_CONFIG.sheetId, range: targetRangeForId }); 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, sheetName = 'Generated_Articles') { logSh(`🗄️ Création sheet ${sheetName}...`, 'INFO'); try { // Créer la nouvelle sheet await sheets.spreadsheets.batchUpdate({ spreadsheetId: SHEET_CONFIG.sheetId, resource: { requests: [{ addSheet: { properties: { title: sheetName } } }] } }); // Headers avec versioning const headers = [ 'Timestamp', 'Slug', 'MC0', 'T0', 'Personality', 'AntiDetection_Level', 'Compiled_Text', // ← COLONNE PRINCIPALE 'Text_Length', 'Word_Count', 'Elements_Count', 'LLM_Used', 'Validation_Status', // 🆕 Colonnes de versioning 'Version', // v1.0, v1.1, v1.2, v2.0 'Stage', // initial_generation, selective_enhancement, etc. 'Stage_Description', // Description détaillée de l'étape 'Parent_Article_ID', // ID de l'article parent (pour linkage) 'GPTZero_Score', // Scores détecteurs (à remplir) 'Originality_Score', 'CopyLeaks_Score', 'Human_Quality_Score', 'Full_Metadata_JSON' // Backup complet avec historique ]; // Ajouter les headers await sheets.spreadsheets.values.update({ spreadsheetId: SHEET_CONFIG.sheetId, range: `${sheetName}!A1:U1`, 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, sheetName), 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 ${sheetName} 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' || sheetName === 'Generated_Articles_Versioned') { await createArticlesStorageSheet(sheets, sheetName); 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 };