Migrer ses posts Instagram vers Ghost

Migrer ses posts Instagram vers Ghost
Photo by Chris Briggs / Unsplash

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.

  1. 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.

  1. Connectez-vous à votre admin Ghost (http://localhost:8080/ghost).
  2. Cliquez sur la roue dentée (Settings) en bas à gauche.
  3. Allez dans Integrations.
  4. Cliquez sur + Add custom integration (tout en bas).
  5. Nommez-la Import Instagram.
  6. 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.

É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

  1. Assurez-vous d'avoir votre environnement activé ((env_ghost)).
  2. Le script va vous demander le chemin.
  3. Ouvrez votre explorateur de fichiers Ubuntu, allez sur votre dossier instagram-data....
  4. Faites Clic Droit -> Propriétés (ou copiez le chemin en haut de la fenêtre).
  5. Collez le chemin dans le terminal (Ex: /home/$user/Downloads/instagram-data...) et faites Entré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 !