seogeneratorserver/lib/DigitalOceanWorkflow.js
StillHammer dbf1a3de8c Add technical plan for multi-format export system
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>
2025-11-18 16:14:29 +08:00

464 lines
15 KiB
JavaScript

// ========================================
// 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<object>} - 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
};