Added plan.md with complete architecture for format-agnostic content generation: - Support for Markdown, HTML, Plain Text, JSON formats - New FormatExporter module with neutral data structure - Integration strategy with existing ContentAssembly and ArticleStorage - Bonus features: SEO metadata generation, readability scoring, WordPress Gutenberg format - Implementation roadmap with 4 phases (6h total estimated) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
743 lines
23 KiB
JavaScript
743 lines
23 KiB
JavaScript
// ========================================
|
|
// 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
|
|
}; |