Uploading multiple pdf using editor cards

Hello all,
I have about 260 pdf i’d like to upload to a single page using editor cards.
So far, I can either successfully upload a pdf, or create an (empty) editor card, but I can’t create an editor card and feed it the pdf and description I want.
Here’s the code I have today:


import jwt  # PyJWT library
import requests
import datetime
import json
import os
import re

# Configuration Ghost API
GHOST_ADMIN_API_KEY = "YOUR_ADMIN_API_KEY"  # Format: key-id:secret
GHOST_ADMIN_URL = "https://mysite.ghost.io/ghost/api/admin"
UPLOAD_ENDPOINT = f"{GHOST_ADMIN_URL}/files/upload/"
PAGE_ENDPOINT = f"{GHOST_ADMIN_URL}/pages/"
PDF_FILE_PATH = "your_file.pdf"  # Change this to the actual file path
PAGE_SLUG = "telechargement-des-numeros"

def create_jwt():
    """Crée un JWT valide pour l'authentification Ghost Admin API"""
    key_id, secret = GHOST_ADMIN_API_KEY.split(":")
    secret_bytes = bytes.fromhex(secret)  # Convert secret to bytes

    now = datetime.datetime.now(datetime.timezone.utc)

    payload = {
        "iat": int(now.timestamp()),  # Issued At
        "exp": int((now + datetime.timedelta(minutes=5)).timestamp()),  # Expiry (5 min)
        "aud": "/admin/"  # Audience doit être "/admin/"
    }

    token = jwt.encode(payload, secret_bytes, algorithm="HS256", headers={"kid": key_id})
    print("🔄 JWT généré avec succès.")
    return token


def extract_description(file_name):
    """Extrait la description après la date (JJ mois AAAA)"""
    match = re.search(r"\(\d{1,2} \w+ \d{4}\) - (.+)\.pdf$", file_name)
    if match:
        description = match.group(1)
        description = description.replace("♦", "-")  # Remplace les symboles inutiles
        return description.strip()
    else:
        print("⚠️ Impossible d'extraire la description, utilisant un nom par défaut.")
        return "Numéro spécial"


def upload_pdf(file_path):
    """Upload un fichier PDF vers Ghost et retourne l'URL"""
    print(f"🔄 Début de l'upload du fichier : {file_path}")
    token = create_jwt()
    headers = {
        "Authorization": f"Ghost {token}",
        "X-Ghost-Version": "5.110",
    }

    with open(file_path, "rb") as file:
        files = {"file": (file_path, file, "application/pdf")}
        response = requests.post(UPLOAD_ENDPOINT, headers=headers, files=files)

    if response.status_code == 201:
        file_data = response.json().get("files", [{}])[0]
        file_url = file_data.get("url")
        print(f"✅ Fichier uploadé avec succès : {file_url}")
        return file_url
    else:
        print(f"❌ Erreur lors de l'upload : {response.status_code} - {response.text}")
        return None


def add_file_card_to_page(page_slug, file_url, file_name, description):
    """Ajoute un Editor Card de type fichier à une page Ghost"""
    print(f"🔄 Récupération de la page '{page_slug}'...")
    token = create_jwt()
    headers = {
        "Authorization": f"Ghost {token}",
        "Content-Type": "application/json",
    }

    # Récupérer la page existante
    response = requests.get(PAGE_ENDPOINT, headers=headers)
    if response.status_code != 200:
        print(f"❌ Erreur récupération des pages : {response.status_code} - {response.text}")
        return False

    pages = response.json().get("pages", [])
    target_page = next((p for p in pages if p["slug"] == page_slug), None)

    if not target_page:
        print("❌ Page non trouvée !")
        return False

    page_id = target_page["id"]
    updated_at = target_page["updated_at"]  # ✅ Récupération de `updated_at`
    print(f"✅ Page trouvée ! ID : {page_id}, Dernière mise à jour : {updated_at}")

    # Vérifier si la page utilise Lexical (Ghost Editor)
    if "lexical" in target_page:
        content_field = "lexical"
        current_content = json.loads(target_page["lexical"])
    else:
        print("❌ La page ne supporte pas Lexical.")
        return False

    print("🔄 Ajout du fichier sous forme d'Editor Card...")

    # Récupérer les métadonnées du fichier
    file_extension = file_name.split(".")[-1]
    file_size = os.path.getsize(PDF_FILE_PATH)

    # Nouvelle structure complète pour l'Editor Card
    new_card = {
        "type": "file",
        "version": 1,
        "file": {
            "url": file_url,
            "caption": description,
            "mimeType": "application/pdf",
            "extension": file_extension,
            "name": file_name,
            "size": file_size
        },
        "children": []  # ✅ Ghost semble attendre un champ `children` même vide
    }

    # Vérifier si c'est la première insertion ou une mise à jour
    if "root" in current_content and "children" in current_content["root"]:
        current_content["root"]["children"].append(new_card)
    else:
        # Si le contenu est vide, on initialise un nouveau document Lexical
        current_content = {
            "root": {
                "type": "root",
                "children": [new_card]
            }
        }

    # Préparer la mise à jour avec `updated_at`
    update_payload = {
        "pages": [
            {
                "id": page_id,
                "updated_at": updated_at,  # ✅ Ajout de `updated_at`
                content_field: json.dumps(current_content)
            }
        ]
    }

    print(f"🔄 Mise à jour de la page {page_slug} avec le fichier...")

    # Envoyer la mise à jour
    update_response = requests.put(f"{PAGE_ENDPOINT}{page_id}/", headers=headers, json=update_payload)

    if update_response.status_code == 200:
        print(f"✅ Page mise à jour avec le fichier ! {file_url}")
        return True
    else:
        print(f"❌ Erreur mise à jour de la page : {update_response.status_code} - {update_response.text}")
        return False


if __name__ == "__main__":
    print("🚀 Script de mise à jour Ghost démarré.")

    # Extraire le nom et la description du fichier
    file_name = os.path.basename(PDF_FILE_PATH)
    description = extract_description(file_name)
    print(f"📂 Fichier détecté : {file_name}")
    print(f"📝 Description extraite : {description}")

    # Upload du fichier
    file_url = upload_pdf(PDF_FILE_PATH)
    
    if file_url:
        print("🔄 Ajout du fichier à la page...")
        add_file_card_to_page(PAGE_SLUG, file_url, file_name, description)
    else:
        print("❌ Aucun fichier n'a été uploadé, arrêt du script.")

I’ve been looking around the header and API but can’t find anything; I understand file upload might be early access but any help would be more than welcome
Thanks!

So you should be able to do this. I’m struggling a bit between the Python and the French, but I think the basic idea is OK.

So I did what I always do, and went and snooped in the network call when I created a file card in the editor. Here’s what I saw. (Sorry, there’s an extra paragraph along for the ride.)

{
  "root": {
    "children": [
      {
        "type": "file",
        "src": "https://demo.spectralwebservices.com/content/files/2025/03/routes--4-.yaml",
        "fileTitle": "YYY",
        "fileCaption": "",
        "fileName": "xxxx.txt",
        "fileSize": 287
      },
      {
        "children": [],
        "direction": null,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "version": 1
      }
    ],
    "direction": null,
    "format": "",
    "indent": 0,
    "type": "root",
    "version": 1
  }
}

It may be worth more closely mimicking what the built in editor is sending. I see src vs url, and you’ve got a different structure (title nested within file vs fileTitle, etc)

1 Like

Awesome, it worked indeed ! Thanks a lot!

1 Like

Please share an example that works so that future users can find the solution! :)

Good point ! Here’s the updated script :)

import jwt  # PyJWT library
import requests
import datetime
import json
import os
import re
import unicodedata
import argparse  # ✅ Added argument parsing

# Configuration Ghost API
GHOST_ADMIN_API_KEY = "xxxx:yyyyyyy"   
GHOST_ADMIN_URL = "https://mysite/ghost/api/admin"
UPLOAD_ENDPOINT = f"{GHOST_ADMIN_URL}/files/upload/"
PAGE_ENDPOINT = f"{GHOST_ADMIN_URL}/pages/"
PAGE_SLUG = "page-to-update"

def create_jwt():
    """Creates a valid JWT for Ghost Admin API authentication"""
    key_id, secret = GHOST_ADMIN_API_KEY.split(":")
    secret_bytes = bytes.fromhex(secret)  

    now = datetime.datetime.now(datetime.timezone.utc)

    payload = {
        "iat": int(now.timestamp()),  
        "exp": int((now + datetime.timedelta(minutes=5)).timestamp()),  
        "aud": "/admin/"  
    }

    token = jwt.encode(payload, secret_bytes, algorithm="HS256", headers={"kid": key_id})
    print("🔄 JWT generated successfully.")
    return token


def extract_description(file_name):
    """Extracts the issue number and date from the filename, handling Unicode and invisible character issues."""
    normalized_name = unicodedata.normalize("NFKC", file_name)
    normalized_name = re.sub(r"[\u200B-\u200D\uFEFF\u00AD]", "", normalized_name)

    match = re.search(r"Numéro\s*(\d+)\s*\(\s*([\d]{1,2} [^\d()]+ \d{4})\s*\)", normalized_name)

    if match:
        issue_number = match.group(1)
        issue_date = match.group(2)
        return f"Numéro {issue_number} ({issue_date})", issue_number
    else:
        print(f"⚠️ Could not extract the issue number and date from '{file_name}', using default.")
        return "Numéro spécial", "XX"  # Default issue number if extraction fails


def extract_caption(file_name):
    """Extracts everything after the (Date) as the caption."""
    normalized_name = unicodedata.normalize("NFKC", file_name)
    normalized_name = re.sub(r"[\u200B-\u200D\uFEFF\u00AD]", "", normalized_name)

    match = re.search(r"\(\s*([\d]{1,2} [^\d()]+ \d{4})\s*\)\s*-\s*(.+)\.pdf$", normalized_name)

    if match:
        return match.group(2).replace("♦", "-").strip()
    else:
        print(f"⚠️ Could not extract the caption from '{file_name}', using default.")
        return "Sans description"


def upload_pdf(file_path):
    """Uploads a PDF file to Ghost and returns its URL"""
    print(f"🔄 Uploading file: {file_path}")
    token = create_jwt()
    headers = {
        "Authorization": f"Ghost {token}",
        "X-Ghost-Version": "5.110",
    }

    with open(file_path, "rb") as file:
        files = {"file": (os.path.basename(file_path), file, "application/pdf")}
        response = requests.post(UPLOAD_ENDPOINT, headers=headers, files=files)

    if response.status_code == 201:
        file_data = response.json().get("files", [{}])[0]
        file_url = file_data.get("url")
        print(f"✅ File uploaded successfully: {file_url}")
        return file_url
    else:
        print(f"❌ Upload error: {response.status_code} - {response.text}")
        return None


def add_file_card_to_page(page_slug, file_url, file_name, file_path, description, caption):
    """Adds a File Editor Card to a Ghost page"""
    print(f"🔄 Retrieving page '{page_slug}'...")
    token = create_jwt()
    headers = {
        "Authorization": f"Ghost {token}",
        "Content-Type": "application/json",
    }

    response = requests.get(PAGE_ENDPOINT, headers=headers)
    if response.status_code != 200:
        print(f"❌ Error retrieving pages: {response.status_code} - {response.text}")
        return False

    pages = response.json().get("pages", [])
    target_page = next((p for p in pages if p["slug"] == page_slug), None)

    if not target_page:
        print("❌ Page not found!")
        return False

    page_id = target_page["id"]
    updated_at = target_page["updated_at"]  
    print(f"✅ Page found! ID: {page_id}, Last updated: {updated_at}")

    print("🔄 Adding file as an Editor Card...")

    file_size = os.path.getsize(file_path)  # ✅ Using the correct file path here

    # New Lexical block exactly matching Ghost's request format
    new_file_card = {
        "type": "file",
        "src": file_url,
        "fileTitle": description,  # Title = "Numéro XX (Date)"
        "fileCaption": caption,    # Caption = everything after (Date)
        "fileName": file_name,
        "fileSize": file_size
    }

    # Paragraph to ensure correct Ghost formatting
    paragraph_block = {
        "children": [],
        "direction": None,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "version": 1
    }

    # Ensure root structure is correct
    current_content = json.loads(target_page["lexical"])
    if "root" in current_content and "children" in current_content["root"]:
        current_content["root"]["children"].append(new_file_card)
        current_content["root"]["children"].append(paragraph_block)
    else:
        current_content = {
            "root": {
                "type": "root",
                "children": [new_file_card, paragraph_block],
                "direction": None,
                "format": "",
                "indent": 0,
                "version": 1
            }
        }

    update_payload = {
        "pages": [
            {
                "id": page_id,
                "updated_at": updated_at,  
                "lexical": json.dumps(current_content)
            }
        ]
    }

    print(f"🔄 Updating page {page_slug} with the file...")

    update_response = requests.put(f"{PAGE_ENDPOINT}{page_id}/", headers=headers, json=update_payload)

    if update_response.status_code == 200:
        print(f"✅ Page updated with the file! {file_url}")
        return True
    else:
        print(f"❌ Page update error: {update_response.status_code} - {update_response.text}")
        return False


if __name__ == "__main__":
    # ✅ Parse command-line arguments
    parser = argparse.ArgumentParser(description="Upload a PDF to Ghost and add it as an Editor Card.")
    parser.add_argument("-f", "--file", required=True, help="Path to the PDF file")
    args = parser.parse_args()

    PDF_FILE_PATH = args.file  # ✅ Use the file passed as a parameter

    print("🚀 Ghost update script started.")

    file_name = os.path.basename(PDF_FILE_PATH)
    description, issue_number = extract_description(file_name)
    caption = extract_caption(file_name)

    print(f"📂 Detected file: {file_name}")
    print(f"📝 Extracted title: {description}")
    print(f"📝 Extracted caption: {caption}")

    # Upload the original file first
    file_url = upload_pdf(PDF_FILE_PATH)

    if file_url:
        # ✅ Use the renamed file **only for Ghost metadata**, not for the upload
        renamed_file_name = f"Numéro {issue_number}.pdf"

        print("🔄 Adding file to the page...")
        add_file_card_to_page(PAGE_SLUG, file_url, renamed_file_name, PDF_FILE_PATH, description, caption)
    else:
        print("❌ No file uploaded, stopping script.")