Migrer ses posts Instagram vers Ghost
Prérequis
Ce guide suppose que votre Stack Ghost Docker Compose est déjà lancée, que vous êtes sur votre serveur Ubuntu et que vous avez téléchargé vos archives Instagram en format JSON (recommandé) ou HTML.
Nous allons utiliser un script Python "ETL" (Extract, Transform, Load). Il va Extraire les données de vos fichiers Instagram, les Transformer pour qu'elles soient compatibles avec Ghost, et les Charger via l'API.

Étape 1 : Préparation de l'environnement Python
Sur les versions récentes d'Ubuntu, Python est protégé. Nous devons créer un "bocal" (environnement virtuel) pour travailler sans casser le système.
- Ouvrez votre terminal.
Installez les dépendances dans cet environnement :Bash
pip install requests pyjwt beautifulsoup4
Créez le dossier du projet et l'environnement virtuel :Bash
# Créez un dossier pour travailler proprement
mkdir migration_insta
cd migration_insta
# Créez l'environnement virtuel
python3 -m venv env_ghost
# Activez-le (IMPORTANT : à faire à chaque fois que vous revenez)
source env_ghost/bin/activate
(Vous devriez voir (env_ghost) apparaître au début de votre ligne de commande).
Installez les outils nécessaires :Bash
sudo apt update
sudo apt install python3-full nano
Étape 2 : Récupérer la Clé API Ghost
Le script a besoin d'une clé "Admin" pour poster à votre place.
- Connectez-vous à votre admin Ghost (
http://localhost:8080/ghost). - Cliquez sur la roue dentée (Settings) en bas à gauche.
- Allez dans Integrations.
- Cliquez sur + Add custom integration (tout en bas).
- Nommez-la
Import Instagram. - Copiez la clé qui s'appelle Admin API Key (elle commence par des chiffres et contient deux points
:).- Note : L'API URL devrait être
http://localhost:8080.
- Note : L'API URL devrait être
Étape 3 : Créer le script de migration
Choisissez l'option selon le fichier que vous voulez utiliser. L'option JSON est recommandée (plus fiable pour les dates).
Option A : Via le fichier JSON (Recommandé)
Créez le fichier :
Bash
nano migrer_json.py
import os
import requests
import jwt
import sys
import json
from datetime import datetime
# ==========================================
# --- CONFIGURATION (À MODIFIER AVANT DE LANCER) ---
# ==========================================
# Collez votre clé Admin Ghost ici (Admin Settings -> Integrations -> Ghost Admin API)
ADMIN_API_KEY = 'VOTRE_ADMIN_API_KEY_ICI'
# L'URL de votre instance Ghost (avec le slash à la fin)
API_URL = 'http://localhost:8080/ghost/api/admin/'
# ==========================================
# --- FONCTIONS UTILITAIRES ---
def get_jwt_token(key):
"""Génère le token d'authentification temporaire"""
try:
id, secret = key.split(':')
iat = int(datetime.now().timestamp())
header = {'alg': 'HS256', 'typ': 'JWT', 'kid': id}
payload = {'iat': iat, 'exp': iat + 300, 'aud': '/admin/'}
return jwt.encode(payload, bytes.fromhex(secret), algorithm='HS256', headers=header)
except Exception as e:
print(f"❌ Erreur critique : Votre clé API semble invalide.")
print(f" Détail : {e}")
sys.exit(1)
def index_images_by_id(folder_path):
"""
Scanne le dossier pour créer un index { ID_IMAGE : CHEMIN_COMPLET }
Permet de retrouver une image instantanément sans se soucier des dossiers/sous-dossiers.
"""
print(f"📂 Indexation des images dans : {folder_path}...")
image_map = {}
count = 0
# On parcourt récursivement (au cas où il y aurait des sous-dossiers par année)
for root, dirs, files in os.walk(folder_path):
for file in files:
# On ne garde que les extensions d'images valides
if not file.lower().endswith(('.jpg', '.jpeg', '.png', '.webp', '.heic')):
continue
# L'ID est le nom du fichier sans l'extension (ex: '12345' pour '12345.jpg')
file_id = os.path.splitext(file)[0]
full_path = os.path.join(root, file)
image_map[file_id] = full_path
count += 1
print(f"✅ Indexation terminée : {count} images trouvées.")
return image_map
def upload_image(local_path, token):
"""Upload l'image vers Ghost et retourne l'URL"""
if not local_path: return None
url = f"{API_URL}images/upload/"
try:
with open(local_path, 'rb') as f:
# Détection basique du type MIME
ext = os.path.splitext(local_path)[1].lower()
mime = 'image/jpeg'
if ext == '.webp': mime = 'image/webp'
elif ext == '.png': mime = 'image/png'
files = {
'file': (os.path.basename(local_path), f, mime),
'purpose': (None, 'image'),
'ref': (None, local_path)
}
r = requests.post(url, headers={'Authorization': f'Ghost {token}'}, files=files)
r.raise_for_status()
return r.json()['images'][0]['url']
except Exception as e:
print(f" ❌ Erreur upload image ({os.path.basename(local_path)}) : {e}")
return None
def create_ghost_post(title, html, timestamp, feature_img, token):
"""Crée le post dans Ghost avec la bonne date"""
url = f"{API_URL}posts/?source=html"
# Gestion de la date (Timestamp Unix -> Date Objet)
try:
if timestamp and int(timestamp) > 0:
date_obj = datetime.fromtimestamp(int(timestamp))
else:
date_obj = datetime.now()
except:
date_obj = datetime.now()
# Ghost ne permet le statut "published" QUE si la date est passée.
# Si la date est future (erreur d'horloge ou autre), on force "scheduled".
status = "scheduled" if date_obj > datetime.now() else "published"
# Formatage ISO 8601 UTC requis par Ghost
date_str = date_obj.strftime('%Y-%m-%dT%H:%M:%S') + 'Z'
post_data = {
"title": title,
"html": html,
"status": status,
"published_at": date_str
}
if feature_img:
post_data["feature_image"] = feature_img
try:
r = requests.post(url, headers={'Authorization': f'Ghost {token}'}, json={'posts': [post_data]})
r.raise_for_status()
print(f"✅ Post créé : {title[:40]}... (Date: {date_obj.strftime('%d/%m/%Y')})")
except requests.exceptions.HTTPError:
# Si Ghost refuse (souvent à cause d'une date trop vieille ou bugguée), on sauve en brouillon
print(f"⚠️ Erreur publication -> Sauvegarde en BROUILLON.")
post_data['status'] = 'draft'
post_data.pop('published_at', None) # On laisse Ghost mettre la date du jour pour le brouillon
try:
requests.post(url, headers={'Authorization': f'Ghost {token}'}, json={'posts': [post_data]})
except Exception as e:
print(f"❌ Echec total création post : {e}")
# --- MAIN ---
def main():
print("=========================================")
print(" IMPORTATEUR INSTAGRAM -> GHOST V2")
print("=========================================")
# 1. Demande du chemin des IMAGES
while True:
media_path = input("\n📂 Veuillez coller le chemin du dossier contenant les images (media/posts) :\n> ").strip().strip("'").strip('"')
if os.path.exists(media_path) and os.path.isdir(media_path):
break
print("❌ Ce chemin est invalide ou n'est pas un dossier. Réessayez.")
# 2. Demande du chemin du JSON
while True:
json_path = input("\n📄 Veuillez coller le chemin du fichier JSON (posts_1.json) :\n> ").strip().strip("'").strip('"')
if os.path.exists(json_path) and os.path.isfile(json_path):
break
print("❌ Ce fichier n'existe pas. Réessayez.")
# 3. Indexation des images
image_db = index_images_by_id(media_path)
if not image_db:
print("❌ Aucune image trouvée dans le dossier fourni. Arrêt.")
return
# 4. Lecture du JSON
print(f"\n📖 Lecture du fichier JSON...")
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception as e:
print(f"❌ Erreur de lecture JSON : {e}")
return
# Normalisation de la liste des posts (Instagram change souvent de format)
posts = []
if isinstance(data, list):
posts = data
elif isinstance(data, dict):
# On cherche la clé probable
for key in ['media', 'posts', 'photos', 'feed']:
if key in data and isinstance(data[key], list):
posts = data[key]
break
print(f"🔍 {len(posts)} posts trouvés à analyser.")
# Authentification Ghost
print("🔑 Connexion à l'API Ghost...")
token = get_jwt_token(ADMIN_API_KEY)
count_created = 0
count_skipped = 0
# 5. Boucle principale de traitement
for i, post in enumerate(posts):
# --- A. Gestion du TITRE ---
raw_caption = post.get('title', '')
# Correction d'encodage (Mojibake) fréquent chez Meta
try: caption = raw_caption.encode('latin-1').decode('utf-8')
except: caption = raw_caption
# Si pas de légende, titre = "Photos"
if not caption.strip():
title = "Photos"
else:
# Sinon, on prend la première ligne, coupée à 50 caractères
title = caption.split('\n')[0][:50] + "..." if len(caption) > 50 else caption
# --- B. Recherche des IMAGES (Force Match) ---
# On convertit tout l'objet post en texte pour chercher les IDs des fichiers dedans
post_as_string = json.dumps(post)
ghost_urls = []
# On regarde si l'ID d'une de nos images locales apparaît dans ce post
for img_id, img_path in image_db.items():
if img_id in post_as_string:
# Si oui, on upload vers Ghost
u = upload_image(img_path, token)
if u: ghost_urls.append(u)
# --- C. Filtrage Strict (Règle : Pas d'image = Pas de post) ---
if not ghost_urls:
# On n'affiche pas les erreurs pour ne pas polluer le terminal, on compte juste
count_skipped += 1
continue
# --- D. Construction du HTML ---
html_content = ""
for u in ghost_urls:
html_content += f'<img src="{u}"><br>'
# Ajout de la légende (sauts de lignes convertis en HTML)
html_content += f'<p>{caption.replace(chr(10), "<br>")}</p>'
# --- E. Récupération Date et Publication ---
timestamp = post.get('creation_timestamp', 0)
feature_image = ghost_urls[0] # La première image devient la couverture
create_ghost_post(title, html_content, timestamp, feature_image, token)
count_created += 1
# Bilan
print("\n" + "="*40)
print(f"🎉 TERMINÉ !")
print(f"✅ Posts créés : {count_created}")
print(f"⏩ Ignorés (pas d'images) : {count_skipped}")
print("=========================================")
if __name__ == "__main__":
main()Modifiez la ligne (ADMIN_API_KEY) avec votre clé.
Sauvegardez (Ctrl+O, Entrée, Ctrl+X).
Lancez le script : python migrer_json.py
Le script vous demandera successivement :
- Le chemin du dossier IMAGES (celui avec les
.webp,.jpg). - Le chemin du fichier JSON (
posts_1.json).
Il s'occupera du reste en respectant toutes vos règles.
Option B : Via le fichier HTML
Si vous préférez le HTML, créez le fichier :
Bash
nano migrer_html.py
Copiez-collez ce code et modifiez la ligne ADMIN_API_KEY.
Python
import os, requests, jwt, sys
from bs4 import BeautifulSoup
from datetime import datetime
# --- CONFIGURATION (A MODIFIER) ---
ADMIN_API_KEY = 'COLLEZ_VOTRE_CLE_ADMIN_ICI'
API_URL = 'http://localhost:8080/ghost/api/admin/'
def get_token(key):
id, secret = key.split(':')
iat = int(datetime.now().timestamp())
header = {'alg': 'HS256', 'typ': 'JWT', 'kid': id}
payload = {'iat': iat, 'exp': iat + 300, 'aud': '/admin/'}
return jwt.encode(payload, bytes.fromhex(secret), algorithm='HS256', headers=header)
def find_img(name, folder):
if not name: return None
target = os.path.basename(name).lower()
for root, _, files in os.walk(folder):
for f in files:
if f.lower() == target: return os.path.join(root, f)
return None
def upload(path, token):
if not path: return None
url = f"{API_URL}images/upload/"
try:
with open(path, 'rb') as f:
files = {'file': (os.path.basename(path), f, 'image/jpeg'), 'purpose': (None, 'image'), 'ref': (None, path)}
r = requests.post(url, headers={'Authorization': f'Ghost {token}'}, files=files)
r.raise_for_status()
return r.json()['images'][0]['url']
except: return None
def parse_date(d_str):
map_m = {'jan': 'Jan', 'fév': 'Feb', 'fev': 'Feb', 'mars': 'Mar', 'avr': 'Apr', 'mai': 'May', 'juin': 'Jun', 'juil': 'Jul', 'août': 'Aug', 'sep': 'Sep', 'oct': 'Oct', 'nov': 'Nov', 'déc': 'Dec'}
clean = d_str.lower().replace('.', '').strip()
for fr, en in map_m.items():
if clean.startswith(fr): clean = clean.replace(fr, en, 1); break
try: return datetime.strptime(clean, '%b %d, %Y %I:%M %p')
except: return datetime.now()
def create_post(title, html, date_obj, feat, token):
url = f"{API_URL}posts/?source=html"
status = "scheduled" if date_obj > datetime.now() else "published"
data = {"title": title, "html": html, "status": status, "published_at": date_obj.strftime('%Y-%m-%dT%H:%M:%S') + 'Z'}
if feat: data["feature_image"] = feat
try:
r = requests.post(url, headers={'Authorization': f'Ghost {token}'}, json={'posts': [data]})
r.raise_for_status()
print(f"[OK] {title[:30]}...")
except:
print(f"[!] Erreur (Brouillon) : {title[:30]}")
data['status'] = 'draft'; del data['published_at']
requests.post(url, headers={'Authorization': f'Ghost {token}'}, json={'posts': [data]})
def main():
root = input("Chemin du dossier dézippé : ").strip().strip("'").strip('"')
html_f = None
for r, _, files in os.walk(root):
for f in files:
if f.endswith(".html") and "post" in f: html_f = os.path.join(r, f); break
if not html_f: return print("HTML introuvable")
print(f"Lecture de {html_f}...")
with open(html_f, 'r', encoding='utf-8') as f: soup = BeautifulSoup(f, 'html.parser')
token = get_token(ADMIN_API_KEY)
posts = soup.find_all('div', class_='pam')
for i, p in enumerate(posts):
print(f"Traitement {i+1}/{len(posts)}")
h2 = p.find('h2'); caption = h2.get_text(separator="\n").strip() if h2 else "Souvenir"
title = caption.split('\n')[0][:50] + "..." if len(caption)>50 else caption
d_div = p.find('div', class_='_3-94')
date_obj = parse_date(d_div.get_text()) if d_div else datetime.now()
imgs = p.find_all('img')
urls = []
for img in imgs:
src = img.get('src')
if src and "emoji" not in src:
l = find_img(src, root)
u = upload(l, token)
if u: urls.append(u)
html = "".join([f'<img src="{u}"><br>' for u in urls]) + f'<p>{caption.replace(chr(10), "<br>")}</p>'
create_post(title, html, date_obj, urls[0] if urls else None, token)
if __name__ == "__main__": main()
Sauvegardez avec Ctrl+O -> Entrée -> Ctrl+X.
Étape 4 : Lancer la migration
- Assurez-vous d'avoir votre environnement activé (
(env_ghost)). - Le script va vous demander le chemin.
- Ouvrez votre explorateur de fichiers Ubuntu, allez sur votre dossier
instagram-data.... - Faites
Clic Droit->Propriétés(ou copiez le chemin en haut de la fenêtre). - Collez le chemin dans le terminal (Ex:
/home/$user/Downloads/instagram-data...) et faitesEntrée.
Lancez le script de votre choix :Bash
python migrer_json.py
# OU
python migrer_html.py
Résultat
Le script va défiler et afficher :
[OK] Titre du post...pour les succès.[!] Erreur (Brouillon)...s'il a eu un problème (date invalide, etc.), il sauvegarde quand même en brouillon.
Une fois fini, allez sur votre instance Ghost (http://localhost:8080/ghost), dans la section Posts.
Vous verrez tous vos souvenirs Instagram !