Un de mes clients produit un podcast B2B. Trois épisodes par semaine sur Acast, deux émissions différentes. Il voulait un vrai site à lui — pas une page Spotify — avec les épisodes qui remontent tout seuls. J'ai monté ça avec Ghost et n8n. Ça tourne depuis des semaines sans que le client y touche. Voici comment.
Pourquoi un site à part
Petit détour avant la technique, parce que la question revient à chaque fois : "pourquoi s'embêter avec un site alors que tout est déjà sur Spotify ?"
Parce que Spotify, Apple, Deezer — ces plateformes distribuent votre podcast, mais la relation avec vos auditeurs, c'est elles qui la gardent. Vous n'avez pas leurs emails. Vous ne pouvez pas leur envoyer une newsletter. Vous ne pouvez pas leur proposer un abonnement payant. Et si l'algo change ou que la plateforme pivote, vous n'avez plus rien.
Avec un site Ghost, le podcasteur a son domaine, sa newsletter intégrée, son système de membership natif, et chaque épisode est une page web indexée par Google. Le podcast reste distribué partout via Acast — mais en plus, il a un endroit à lui. C'est ce "en plus" qui change tout.
J'auto-héberge Ghost sur un VPS. Ça coûte une fraction de Ghost Pro, et je garde la main sur tout.
Le brief
Concrètement, voici ce qu'il fallait :
- Chaque épisode publié sur Acast apparaît sur le site dans l'heure
- Avec le player audio intégré (l'auditeur écoute directement sur le site)
- La description, l'image, le bon tag selon le type d'émission
- La date originale de publication (pas la date d'import)
- Et surtout : zéro doublon, même si le workflow tourne 24 fois par jour
La stack
| Quoi | Avec quoi |
|---|---|
| Hébergement podcast | Acast (flux RSS public) |
| Site web | Ghost v5+ (auto-hébergé, API Admin) |
| Automatisation | n8n (self-hosted) |
| Rythme | Toutes les heures pour un podcast, quotidien pour l'autre |
Comment ça marche
Récupérer les épisodes
Acast expose un flux RSS pour chaque émission. Dans n8n, je fais un GET sur l'URL du flux et je parse le XML. Rien de sorcier :
https://feeds.acast.com/public/shows/{slug-du-show}Ça me donne un JSON avec tous les épisodes dans rss.channel.item.
Transformer en posts Ghost
Un nœud Code en JavaScript fait le gros du boulot. Pour chaque épisode, j'extrais les infos :
javascript
const title = item.title || '';
const description = item.description || '';
const pubDate = item.pubDate || '';
const audioUrl = item.enclosure?.url || '';
const imageUrl = item['itunes:image']?.href || '';
// L'ID Acast sert à construire l'URL du player embarqué
const episodeId = item['acast:episodeId']
|| audioUrl.match(/\/([a-f0-9-]+)\.mp3/)?.[1]
|| '';Le champ acast:episodeId est propre au namespace Acast dans le XML. Quand il est absent, je me rabats sur le nom du fichier MP3.
Tagger automatiquement
Les titres du podcast suivent une convention stable ("L'Hebdo de…", "Interview avec…"). J'en profite :
javascript
let tag = 'episode';
const titleLower = title.toLowerCase();
if (titleLower.includes("l'hebdo") || titleLower.includes("lhebdo")) {
tag = 'hebdo';
} else if (titleLower.includes("interview")) {
tag = 'interview';
} else if (titleLower.includes("stories")) {
tag = 'stories';
}Pas besoin de faire plus compliqué.
Construire le contenu au format Lexical
Ghost v5 a remplacé Mobiledoc par Lexical. Pour injecter du HTML (l'iframe Acast + la description), j'utilise un bloc html :
javascript
const html = `<iframe
src="https://embed.acast.com/${showSlug}/${episodeId}"
width="100%" height="200" frameborder="0">
</iframe>
${description}`;
const lexical = JSON.stringify({
root: {
children: [
{ type: "html", version: 1, html: html }
],
direction: null,
format: "",
indent: 0,
type: "root",
version: 1
}
});La structure Lexical doit être exacte. Il faut direction, format, indent, type et version dans le root. S'il manque un seul champ, l'API retourne une 422 — et le message d'erreur ne dit pas lequel. J'ai tourné en rond un moment avant de comprendre.
La déduplication — le vrai sujet
C'est là que j'ai le plus galéré. Le workflow tourne toutes les heures, donc il doit savoir quels épisodes existent déjà sur le site. Ça a l'air simple. Ça ne l'est pas.
Ce que j'ai essayé d'abord : comparer les slugs
L'idée était logique : je génère un slug à partir du titre côté JavaScript, et je demande à Ghost si un post avec ce slug existe.
javascript
// ⚠️ Mauvaise idée
const slug = title.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');Sauf que Ghost fabrique ses slugs avec sa propre cuisine. Et avec des titres en français, ça diverge très vite :
| Titre | Mon JS | Ghost |
|---|---|---|
L'Hebdo | l-hebdo | lhebdo |
février | f-vrier | fevrier |
é, è, ê | supprimés → - | e |
Mon slug ne correspond jamais à celui de Ghost → la vérification dit "ça n'existe pas" → l'épisode est recréé. Et Ghost ne bronche pas : il colle un -2, -3 à la fin du slug. Résultat : des dizaines de doublons, aucune erreur dans les logs.
J'ai essayé d'améliorer en translitérant les accents :
javascript
// Mieux, mais pas assez
const slug = title
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/['\u2019']/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');Ça règle 90% des cas. Mais il reste des edge cases (tirets cadratins, espaces insécables…) et surtout, le principe même est bancal. On ne peut pas reproduire fidèlement la logique de slug de Ghost côté client.
Ce qui marche : comparer les titres
J'ai changé d'approche. Au lieu de vérifier slug par slug, je récupère les 20 derniers posts Ghost en une requête, et je compare les titres :
javascript
const ghostData = $('Fetch Ghost Posts').first().json;
const existingTitles = new Set(
(ghostData.posts || []).map(p => p.title.trim().toLowerCase())
);
const rssData = $('Parse XML').first().json;
const latestItems = rssData.rss.channel.item.slice(0, 5);
const newEpisodes = [];
for (const item of latestItems) {
const title = item.title || '';
if (existingTitles.has(title.trim().toLowerCase())) {
console.log(`SKIP: ${title}`);
continue;
}
console.log(`NEW: ${title}`);
// construire l'objet post...
}Le titre dans le RSS et le titre dans Ghost sont identiques — Ghost ne le modifie pas à la création. Une comparaison lowercase + trim suffit.
La requête Ghost :
GET /ghost/api/admin/posts/?limit=20&fields=title&order=published_at descJe ne demande que les titres pour garder la réponse légère. Pour le second podcast, j'ajoute &filter=tag:nom-du-podcast pour ne pas mélanger.
Truc utile dans n8n : $('Nom du noeud').first().json permet de lire les données de n'importe quel nœud précédent depuis un nœud Code, même s'il n'est pas branché en entrée directe. C'est comme ça que je récupère à la fois le RSS parsé et les posts Ghost dans le même script.Créer le post
Un POST vers l'API Ghost Admin :
json
{
"posts": [{
"title": "Mon épisode",
"lexical": "{\"root\":{...}}",
"status": "published",
"published_at": "2026-02-08T16:20:35.000Z",
"tags": ["hebdo"],
"feature_image": "https://assets.pippa.io/..."
}]
}L'API attend un tableau posts même pour un seul post. Si vous oubliez les crochets → 422.
Quand il n'y a rien de nouveau, le script retourne { _skip: true } et un nœud If court-circuite le POST.
Le workflow complet
Toutes les heures
→ GET flux RSS Acast
→ Parse XML
→ GET 20 derniers posts Ghost
→ Code : compare les titres, filtre les nouveaux
→ If : skip si rien de neuf
→ POST nouveau post dans GhostSix nœuds, tout en ligne. Pas de branche parallèle, pas de Merge. Chaque nœud fait un truc.
Ce qui m'a fait perdre du temps
La perte de données entre nœuds n8n. Quand un nœud HTTP s'exécute, sa réponse écrase les données de l'item. Si vous chaînez RSS → Check API → Create, le nœud Create ne voit plus rien du RSS. La parade : référencer les nœuds par leur nom dans le Code ($('Parse XML').first().json), ou tout consolider en amont.
Le Merge de n8n. En mode "Wait for All Inputs", il peut ne rien retourner si les branches n'ont pas le même nombre d'items. Je ne l'utilise plus pour ce genre de workflow — l'approche séquentielle est plus prévisible.
Les credentials Ghost. Elles ne s'exportent pas avec le JSON du workflow. À chaque import, il faut les reconfigurer à la main. La clé API se trouve dans Ghost sous Settings → Integrations → Custom Integration.
Le format Lexical. Voici la structure minimale qui passe :
json
{
"root": {
"children": [
{ "type": "html", "version": 1, "html": "<p>contenu</p>" }
],
"direction": null,
"format": "",
"indent": 0,
"type": "root",
"version": 1
}
}Enlevez un champ, n'importe lequel, et c'est une 422.
Monter un second podcast sur la même infra
J'ai dupliqué le workflow pour une seconde émission en changeant quatre choses :
- L'URL du flux RSS
- Le tag Ghost
- Le filtre sur la requête GET (
&filter=tag:...) - La fréquence (quotidienne au lieu d'horaire)
Le script reste le même. Ça m'a pris une soirée.
Bilan
Le workflow tourne depuis des semaines. Chaque épisode publié sur Acast apparaît sur le site dans l'heure. Deux podcasts cohabitent sur la même instance Ghost. Zéro doublon, zéro intervention manuelle côté client.
Derrière, je surveille que tout roule : mises à jour Ghost et n8n, sauvegardes quotidiennes, renouvellement SSL, monitoring du workflow de syndication. Si Acast change un truc dans son flux RSS ou si Ghost sort une mise à jour qui casse le format Lexical, c'est moi qui m'en occupe. Le podcasteur, lui, n'a qu'une chose à faire : publier ses épisodes.
Le podcasteur a maintenant un site à son nom, indexé par Google, avec une newsletter et la possibilité de proposer des abonnements payants — tout en restant sur Spotify, Apple et les autres. Il n'a rien eu à quitter pour gagner en indépendance.
Montage complet (Ghost + thème + workflows) : une semaine. Et depuis, c'est moi qui gère l'infra — le client n'a rien eu à toucher.
C'est le genre de mission que je fais chez Marketing Robot : brancher des sources de contenu sur vos plateformes web, et faire en sorte que ça tourne sans vous. Si vous publiez régulièrement et que la republication à la main vous fatigue, écrivez-moi.