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>
544 lines
15 KiB
Markdown
544 lines
15 KiB
Markdown
# 🚀 Plan Technique - Système Multi-Format d'Export
|
|
|
|
## 📋 Vue d'ensemble
|
|
|
|
**Objectif**: Ajouter un système d'export multi-format au générateur SEO pour produire du contenu dans différents formats (Markdown, HTML, Plain Text, JSON).
|
|
|
|
**Principe**: Séparer la génération de contenu de sa présentation en stockant le contenu dans une structure de données neutre, puis en fournissant des convertisseurs vers chaque format.
|
|
|
|
|
|
## 🎯 Formats à supporter
|
|
|
|
### 1. Markdown (.md) - PRIORITÉ #1
|
|
- Format le plus demandé pour les blogs
|
|
- Compatible WordPress, Ghost, Jekyll, Hugo
|
|
- Lisible et éditable facilement
|
|
|
|
### 2. Plain Text (.txt) - PRIORITÉ #2
|
|
- Format universel
|
|
- Pas de formatage, copier-coller direct
|
|
|
|
### 3. HTML (.html) - PRIORITÉ #3
|
|
- Format déjà généré par le système actuel
|
|
- Nécessaire pour certains CMS
|
|
|
|
### 4. JSON - Pour intégration API
|
|
- Format structuré
|
|
- Utile pour les intégrations programmatiques
|
|
|
|
---
|
|
|
|
## 🏗️ Architecture proposée
|
|
|
|
### Structure de données neutre
|
|
|
|
Au lieu de générer directement du XML/HTML, stocker le contenu dans une structure intermédiaire:
|
|
|
|
```javascript
|
|
{
|
|
title: "Titre principal",
|
|
meta_description: "Meta description SEO",
|
|
intro: "Texte d'introduction",
|
|
sections: [
|
|
{
|
|
heading: "Titre section 1",
|
|
level: 2, // h2
|
|
content: "Contenu de la section..."
|
|
},
|
|
{
|
|
heading: "Sous-section 1.1",
|
|
level: 3, // h3
|
|
content: "Contenu de la sous-section..."
|
|
}
|
|
],
|
|
conclusion: "Texte de conclusion",
|
|
metadata: {
|
|
word_count: 1247,
|
|
keywords: ["mot-clé1", "mot-clé2"],
|
|
generated_at: "2025-09-06T..."
|
|
}
|
|
}
|
|
```
|
|
|
|
### Nouveau module: lib/FormatExporter.js
|
|
|
|
```javascript
|
|
class FormatExporter {
|
|
constructor(contentStructure) {
|
|
this.content = contentStructure;
|
|
}
|
|
|
|
toMarkdown() {
|
|
let md = `# ${this.content.title}\n\n`;
|
|
md += `${this.content.intro}\n\n`;
|
|
|
|
for (const section of this.content.sections) {
|
|
const hashes = '#'.repeat(section.level);
|
|
md += `${hashes} ${section.heading}\n\n`;
|
|
md += `${section.content}\n\n`;
|
|
}
|
|
|
|
if (this.content.conclusion) {
|
|
md += `## Conclusion\n\n${this.content.conclusion}\n`;
|
|
}
|
|
|
|
return md;
|
|
}
|
|
|
|
toHTML() {
|
|
let html = `<h1>${this.content.title}</h1>\n`;
|
|
html += `<p>${this.content.intro}</p>\n`;
|
|
|
|
for (const section of this.content.sections) {
|
|
html += `<h${section.level}>${section.heading}</h${section.level}>\n`;
|
|
html += `<p>${section.content}</p>\n`;
|
|
}
|
|
|
|
if (this.content.conclusion) {
|
|
html += `<h2>Conclusion</h2>\n<p>${this.content.conclusion}</p>\n`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
toPlainText() {
|
|
let txt = `${this.content.title}\n\n`;
|
|
txt += `${this.content.intro}\n\n`;
|
|
|
|
for (const section of this.content.sections) {
|
|
txt += `${section.heading}\n`;
|
|
txt += `${section.content}\n\n`;
|
|
}
|
|
|
|
if (this.content.conclusion) {
|
|
txt += `Conclusion\n${this.content.conclusion}\n`;
|
|
}
|
|
|
|
return txt;
|
|
}
|
|
|
|
toJSON() {
|
|
return JSON.stringify(this.content, null, 2);
|
|
}
|
|
|
|
export(format = 'markdown') {
|
|
const exporters = {
|
|
'markdown': this.toMarkdown.bind(this),
|
|
'md': this.toMarkdown.bind(this),
|
|
'html': this.toHTML.bind(this),
|
|
'text': this.toPlainText.bind(this),
|
|
'txt': this.toPlainText.bind(this),
|
|
'json': this.toJSON.bind(this)
|
|
};
|
|
|
|
if (format.toLowerCase() in exporters) {
|
|
return exporters[format.toLowerCase()]();
|
|
}
|
|
|
|
throw new Error(`Format ${format} non supporté`);
|
|
}
|
|
}
|
|
|
|
module.exports = { FormatExporter };
|
|
```
|
|
|
|
---
|
|
|
|
## 🔧 Intégration au système existant
|
|
|
|
### Modification de lib/ContentAssembly.js
|
|
|
|
Actuellement, ContentAssembly génère directement du XML. Il faudra:
|
|
|
|
1. **Extraire la structure** du contenu généré
|
|
2. **Créer l'objet neutre** au lieu de retourner directement le XML
|
|
3. **Appeler FormatExporter** pour générer le format souhaité
|
|
|
|
```javascript
|
|
// Avant
|
|
function assembleContent(elements, xmlTemplate) {
|
|
let result = xmlTemplate;
|
|
// Remplace les balises dans le XML
|
|
return result; // Retourne XML
|
|
}
|
|
|
|
// Après
|
|
function assembleContent(elements, xmlTemplate, options = {}) {
|
|
// Construire la structure neutre
|
|
const contentStructure = buildContentStructure(elements);
|
|
|
|
// Exporter au format demandé
|
|
const exporter = new FormatExporter(contentStructure);
|
|
const format = options.outputFormat || 'markdown';
|
|
|
|
return exporter.export(format);
|
|
}
|
|
|
|
function buildContentStructure(elements) {
|
|
// Parser les éléments générés et construire l'objet
|
|
return {
|
|
title: elements.find(e => e.tag === 'h1')?.content || '',
|
|
intro: elements.find(e => e.tag === 'intro')?.content || '',
|
|
sections: buildSections(elements),
|
|
conclusion: elements.find(e => e.tag === 'conclusion')?.content || '',
|
|
metadata: {
|
|
word_count: calculateWordCount(elements),
|
|
generated_at: new Date().toISOString()
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
### Modification de lib/ArticleStorage.js
|
|
|
|
Ajouter un paramètre pour sauvegarder dans différents formats:
|
|
|
|
```javascript
|
|
async function saveGeneratedArticle(contentStructure, options = {}) {
|
|
const exporter = new FormatExporter(contentStructure);
|
|
|
|
// Sauvegarder en markdown par défaut pour Google Sheets
|
|
const markdownContent = exporter.toMarkdown();
|
|
|
|
// Sauvegarder dans Google Sheets
|
|
await appendToSheet('Generated_Articles', [..., markdownContent]);
|
|
|
|
// Optionnel: sauvegarder aussi en HTML, JSON, etc.
|
|
if (options.saveAllFormats) {
|
|
const html = exporter.toHTML();
|
|
const json = exporter.toJSON();
|
|
// Sauvegarder dans des fichiers locaux ou autres sheets
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 Plan d'implémentation
|
|
|
|
### Phase 1: Création du module FormatExporter (2h)
|
|
- [ ] Créer `lib/FormatExporter.js`
|
|
- [ ] Implémenter `toMarkdown()`
|
|
- [ ] Implémenter `toHTML()`
|
|
- [ ] Implémenter `toPlainText()`
|
|
- [ ] Implémenter `toJSON()`
|
|
- [ ] Tests unitaires
|
|
|
|
### Phase 2: Adaptation de ContentAssembly (2h)
|
|
- [ ] Créer `buildContentStructure(elements)`
|
|
- [ ] Parser les éléments XML existants en structure neutre
|
|
- [ ] Intégrer FormatExporter dans le workflow
|
|
- [ ] Tests d'intégration
|
|
|
|
### Phase 3: Mise à jour ArticleStorage (1h)
|
|
- [ ] Adapter la sauvegarde Google Sheets pour Markdown
|
|
- [ ] Option pour sauvegarder en multi-formats
|
|
- [ ] Tests de sauvegarde
|
|
|
|
### Phase 4: Tests et validation (1h)
|
|
- [ ] Test génération complète avec différents formats
|
|
- [ ] Validation import WordPress (Markdown)
|
|
- [ ] Vérification préservation du contenu
|
|
- [ ] Tests de performance
|
|
|
|
---
|
|
|
|
## ✅ Critères de validation
|
|
|
|
- ✅ Tous les formats génèrent le même contenu (structure équivalente)
|
|
- ✅ Markdown s'importe correctement dans WordPress
|
|
- ✅ HTML est identique à la version XML actuelle
|
|
- ✅ Plain text est lisible sans artefacts
|
|
- ✅ JSON est bien formé et parsable
|
|
- ✅ Aucune perte d'information entre formats
|
|
- ✅ Performance: < 50ms pour conversion de format
|
|
|
|
---
|
|
|
|
## 🔬 Tests à effectuer
|
|
|
|
```javascript
|
|
// Test 1: Génération multi-format
|
|
const content = await generateContent(...);
|
|
const exporter = new FormatExporter(content);
|
|
|
|
const md = exporter.toMarkdown();
|
|
const html = exporter.toHTML();
|
|
const txt = exporter.toPlainText();
|
|
const json = exporter.toJSON();
|
|
|
|
// Vérifier que tous contiennent le même titre
|
|
assert(md.includes(content.title));
|
|
assert(html.includes(content.title));
|
|
assert(txt.includes(content.title));
|
|
|
|
// Test 2: Préservation du formatage
|
|
// Vérifier que les balises h2, h3 sont correctement converties
|
|
|
|
// Test 3: Import WordPress
|
|
// Copier le Markdown généré dans WordPress et vérifier le rendu
|
|
```
|
|
|
|
---
|
|
|
|
## 🔥 Features bonus (optionnel)
|
|
|
|
### 1. Génération automatique de métadonnées SEO
|
|
|
|
```javascript
|
|
class FormatExporter {
|
|
// ... méthodes existantes ...
|
|
|
|
generateSEOMeta() {
|
|
const slug = this.content.title
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '') // Enlever accents
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
|
|
return {
|
|
title: this.content.title.substring(0, 60), // Max 60 caractères
|
|
description: this.content.meta_description?.substring(0, 160) ||
|
|
this.content.intro?.substring(0, 160), // Max 160 caractères
|
|
keywords: this.content.metadata?.keywords?.join(', ') || '',
|
|
slug: slug,
|
|
canonical: `https://example.com/${slug}`,
|
|
og_title: this.content.title,
|
|
og_description: this.content.meta_description || this.content.intro?.substring(0, 160),
|
|
og_type: 'article',
|
|
twitter_card: 'summary_large_image'
|
|
};
|
|
}
|
|
|
|
toHTMLWithMeta() {
|
|
const meta = this.generateSEOMeta();
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${meta.title}</title>
|
|
<meta name="description" content="${meta.description}">
|
|
<meta name="keywords" content="${meta.keywords}">
|
|
<link rel="canonical" href="${meta.canonical}">
|
|
|
|
<!-- Open Graph -->
|
|
<meta property="og:title" content="${meta.og_title}">
|
|
<meta property="og:description" content="${meta.og_description}">
|
|
<meta property="og:type" content="${meta.og_type}">
|
|
|
|
<!-- Twitter Card -->
|
|
<meta name="twitter:card" content="${meta.twitter_card}">
|
|
<meta name="twitter:title" content="${meta.title}">
|
|
<meta name="twitter:description" content="${meta.description}">
|
|
</head>
|
|
<body>
|
|
${this.toHTML()}
|
|
</body>
|
|
</html>`;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Score de lisibilité (Flesch-Kincaid)
|
|
|
|
```javascript
|
|
class FormatExporter {
|
|
// ... méthodes existantes ...
|
|
|
|
calculateReadability() {
|
|
const text = this.toPlainText();
|
|
|
|
// Compter mots
|
|
const words = text.split(/\s+/).filter(w => w.length > 0);
|
|
const wordCount = words.length;
|
|
|
|
// Compter phrases
|
|
const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0);
|
|
const sentenceCount = sentences.length;
|
|
|
|
// Compter syllabes (approximation pour le français)
|
|
const syllables = this.countSyllables(text);
|
|
|
|
// Formule Flesch adaptée pour le français
|
|
const score = 207 - 1.015 * (wordCount / sentenceCount) - 73.6 * (syllables / wordCount);
|
|
|
|
let grade;
|
|
if (score >= 60) grade = 'Facile';
|
|
else if (score >= 30) grade = 'Moyen';
|
|
else grade = 'Difficile';
|
|
|
|
return {
|
|
score: Math.round(score),
|
|
grade: grade,
|
|
word_count: wordCount,
|
|
sentence_count: sentenceCount,
|
|
avg_words_per_sentence: Math.round(wordCount / sentenceCount),
|
|
reading_time_minutes: Math.ceil(wordCount / 200) // ~200 mots/min
|
|
};
|
|
}
|
|
|
|
countSyllables(text) {
|
|
// Approximation: compter les voyelles
|
|
const vowels = text.match(/[aeiouyàéèêëïîôùû]/gi);
|
|
return vowels ? vowels.length : 0;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Export avec statistiques complètes
|
|
|
|
```javascript
|
|
class FormatExporter {
|
|
// ... méthodes existantes ...
|
|
|
|
exportWithStats(format = 'markdown') {
|
|
const content = this.export(format);
|
|
const seo = this.generateSEOMeta();
|
|
const readability = this.calculateReadability();
|
|
|
|
return {
|
|
content: content,
|
|
format: format,
|
|
seo: seo,
|
|
readability: readability,
|
|
metadata: {
|
|
...this.content.metadata,
|
|
exported_at: new Date().toISOString(),
|
|
content_hash: this.generateHash(content) // Pour déduplication
|
|
}
|
|
};
|
|
}
|
|
|
|
generateHash(content) {
|
|
// Simple hash pour identifier le contenu
|
|
const crypto = require('crypto');
|
|
return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Format WordPress Gutenberg (avec blocs)
|
|
|
|
```javascript
|
|
class FormatExporter {
|
|
// ... méthodes existantes ...
|
|
|
|
toWordPressGutenberg() {
|
|
let wp = `<!-- wp:heading {"level":1} -->\n`;
|
|
wp += `<h1>${this.content.title}</h1>\n`;
|
|
wp += `<!-- /wp:heading -->\n\n`;
|
|
|
|
wp += `<!-- wp:paragraph -->\n`;
|
|
wp += `<p>${this.content.intro}</p>\n`;
|
|
wp += `<!-- /wp:paragraph -->\n\n`;
|
|
|
|
for (const section of this.content.sections) {
|
|
wp += `<!-- wp:heading {"level":${section.level}} -->\n`;
|
|
wp += `<h${section.level}>${section.heading}</h${section.level}>\n`;
|
|
wp += `<!-- /wp:heading -->\n\n`;
|
|
|
|
// Séparer les paragraphes
|
|
const paragraphs = section.content.split('\n\n');
|
|
for (const para of paragraphs) {
|
|
if (para.trim()) {
|
|
wp += `<!-- wp:paragraph -->\n`;
|
|
wp += `<p>${para.trim()}</p>\n`;
|
|
wp += `<!-- /wp:paragraph -->\n\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.content.conclusion) {
|
|
wp += `<!-- wp:heading {"level":2} -->\n`;
|
|
wp += `<h2>Conclusion</h2>\n`;
|
|
wp += `<!-- /wp:heading -->\n\n`;
|
|
|
|
wp += `<!-- wp:paragraph -->\n`;
|
|
wp += `<p>${this.content.conclusion}</p>\n`;
|
|
wp += `<!-- /wp:paragraph -->\n`;
|
|
}
|
|
|
|
return wp;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. Intégration dans le workflow
|
|
|
|
Mise à jour de `lib/Main.js` pour utiliser les nouvelles fonctionnalités:
|
|
|
|
```javascript
|
|
async function handleFullWorkflow(data, options = {}) {
|
|
// ... workflow existant ...
|
|
|
|
// Après génération du contenu
|
|
const contentStructure = buildContentStructure(generatedElements);
|
|
const exporter = new FormatExporter(contentStructure);
|
|
|
|
// Export multi-format
|
|
const results = {
|
|
markdown: exporter.export('markdown'),
|
|
html: exporter.export('html'),
|
|
json: exporter.export('json'),
|
|
wordpress: exporter.toWordPressGutenberg(),
|
|
seo: exporter.generateSEOMeta(),
|
|
readability: exporter.calculateReadability()
|
|
};
|
|
|
|
// Sauvegarder dans Google Sheets
|
|
await saveGeneratedArticle(contentStructure, {
|
|
format: options.outputFormat || 'markdown',
|
|
includeStats: true
|
|
});
|
|
|
|
// Retourner résultat complet
|
|
return {
|
|
success: true,
|
|
slug: results.seo.slug,
|
|
formats: {
|
|
markdown: results.markdown,
|
|
html: results.html,
|
|
wordpress: results.wordpress
|
|
},
|
|
stats: {
|
|
word_count: results.readability.word_count,
|
|
reading_time: results.readability.reading_time_minutes,
|
|
readability_score: results.readability.score,
|
|
seo_meta: results.seo
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 Exemple de sortie complète
|
|
|
|
```javascript
|
|
{
|
|
"success": true,
|
|
"slug": "comment-creer-une-plaque-personnalisee",
|
|
"formats": {
|
|
"markdown": "# Comment Créer une Plaque Personnalisée\n\n...",
|
|
"html": "<h1>Comment Créer une Plaque Personnalisée</h1>\n...",
|
|
"wordpress": "<!-- wp:heading {\"level\":1} -->..."
|
|
},
|
|
"stats": {
|
|
"word_count": 1247,
|
|
"reading_time": 7,
|
|
"readability_score": 65,
|
|
"seo_meta": {
|
|
"title": "Comment Créer une Plaque Personnalisée",
|
|
"description": "Découvrez comment créer facilement une plaque personnalisée...",
|
|
"keywords": "plaque personnalisée, gravure, décoration",
|
|
"slug": "comment-creer-une-plaque-personnalisee",
|
|
"canonical": "https://example.com/comment-creer-une-plaque-personnalisee"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|