DEV Community

Cover image for Save Hours Managing Ghost with These Python Scripts
Developer Service
Developer Service

Posted on • Originally published at developer-service.blog

Save Hours Managing Ghost with These Python Scripts

I run the blog at Developer-Service.blog with a self-hosted Ghost instance.

As much as I love Ghost and its editor, I've encountered a handful of limitations in the Ghost Admin interface that have slowed me down.

Things like:

  • Re-categorizing posts in bulk.
  • Quickly searching posts by title.
  • And many others...

Doing some of these things manually, one by one, through the Ghost admin panel wasn’t just inefficient—it was also error-prone.

So I wrote three small but powerful Python scripts that leverage the Ghost Admin API using JWT authentication.

These scripts saved me hours of clicking and let me manage my content programmatically, with zero guesswork.

GitHub repository for all the scripts: https://github.com/nunombispo/GhostContentAPI


SPONSORED By Python's Magic Methods - Beyond init and str

This book offers an in-depth exploration of Python's magic methods, examining the mechanics and applications that make these features essential to Python's design.

Get the eBook


📦 Setup: Ghost Admin API + Python Environment

Before running the scripts, here’s what you need:

Create an Integration in Ghost Admin:

  • Go to Settings → Integrations → Add custom integration.
  • Note the Admin API Key (it looks like XXXXX:YYYYYYYYYYYYY...),
  • Keep in mind that the API URL for your Ghost site is normally your blog URL.

Create a .env file for your credentials:

GHOST_URL=https://your-blog.com
GHOST_ADMIN_API_KEY=YOUR_KEY_ID:YOUR_SECRET
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

pip install python-dotenv pyjwt requests
Enter fullscreen mode Exit fullscreen mode

Every script uses this shared boilerplate for JWT auth and getting the full API URL:

def get_ghost_token():
    """
    Generate JWT token for Ghost Admin API authentication.

    Returns:
        str: JWT token for API authentication

    Raises:
        ValueError: If GHOST_ADMIN_API_KEY is not found in environment variables
        Exception: If token generation fails
    """
    try:
        # Get API key from environment
        key = os.getenv('GHOST_ADMIN_API_KEY')
        if not key:
            raise ValueError("GHOST_ADMIN_API_KEY not found in environment variables")

        # Split the key into ID and SECRET
        id, secret = key.split(':')

        # Prepare header and payload
        iat = int(datetime.now().timestamp())
        header = {'alg': 'HS256', 'typ': 'JWT', 'kid': id}
        payload = {
            'iat': iat,
            'exp': iat + 5 * 60,  # Token expires in 5 minutes
            'aud': '/admin/'
        }

        # Create the token
        token = jwt.encode(
            payload,
            bytes.fromhex(secret),
            algorithm='HS256',
            headers=header
        )

        return token
    except Exception as e:
        logger.error(f"Failed to generate Ghost token: {str(e)}")
        raise

def get_ghost_api_url():
    """
    Get the Ghost Admin API URL from environment variables.

    Returns:
        str: Complete Ghost Admin API URL

    Raises:
        ValueError: If GHOST_URL is not found in environment variables
    """
    base_url = os.getenv('GHOST_URL')
    if not base_url:
        raise ValueError("GHOST_URL not found in environment variables")

    # Remove trailing slash if present
    base_url = base_url.rstrip('/')
    return f"{base_url}/ghost/api/admin"
Enter fullscreen mode Exit fullscreen mode

🔒 Archiving Older Posts for Paid Members

I wanted to convert older posts - anything published before this year - into exclusive content for paying subscribers.

This script does exactly that:

📁 Script: update_posts_to_paid.py

🔍 Filter: posts before Jan 1 this year, not already "paid"

🔄 Change: visibility → paid

How it works:

Here is the update posts function used in that script:

def update_posts_to_paid():
    """
    Update non-paid posts from previous years to paid status.

    This function:
    1. Retrieves all posts that are not paid and were published before the current year
    2. Updates each post's visibility to 'paid'
    3. Maintains the post's updated_at timestamp to prevent conflicts

    Returns:
        int: Number of successfully updated posts

    Raises:
        Exception: If any error occurs during the update process
    """
    try:
        # Get authentication token and API URL
        token = get_ghost_token()
        api_url = get_ghost_api_url()

        # Set up headers
        headers = {
            'Authorization': f'Ghost {token}',
            'Content-Type': 'application/json',
            'Accept-Version': 'v5.0'
        }

        # Calculate the start of the current year
        current_year = datetime.now().year
        start_of_current_year = f"{current_year}-01-01T00:00:00Z"

        # Get posts from previous years that are not paid
        posts_url = f"{api_url}/posts/"
        response = requests.get(
            posts_url,
            headers=headers,
            params={
                'limit': 'all',
                'filter': f'visibility:-paid+published_at:<{start_of_current_year}'
            }
        )
        response.raise_for_status()
        posts = response.json()['posts']

        logger.info(f"Found {len(posts)} posts from previous years that are not paid")

        # Update each post to paid status
        updated_count = 0
        for post in posts:
            try:
                # Get the latest version of the post to ensure we have the current updated_at
                post_url = f"{api_url}/posts/{post['id']}/"
                post_response = requests.get(post_url, headers=headers)
                post_response.raise_for_status()
                current_post = post_response.json()['posts'][0]

                # Create the update data
                update_url = f"{api_url}/posts/{post['id']}/"
                update_data = {
                    'posts': [{
                        'id': post['id'],
                        'visibility': 'paid',
                        'updated_at': current_post['updated_at']
                    }]
                }

                # Update the post to paid status
                update_response = requests.put(
                    update_url,
                    headers=headers,
                    json=update_data
                )
                update_response.raise_for_status()
                updated_count += 1
                logger.info(f"Updated post: {post['title']}")
            except Exception as e:
                logger.error(f"Failed to update post {post['title']}: {str(e)}")
                continue

        logger.info(f"Successfully updated {updated_count} posts to paid status")
        return updated_count

    except Exception as e:
        logger.error(f"An error occurred: {str(e)}")
        raise
Enter fullscreen mode Exit fullscreen mode

Ghost exposes a query language called NQL for filtering API results - see the Content API docs for full details.

Its syntax works much like filters in Gmail, GitHub, or Slack: you specify a field and a value separated by a colon.

A filter expression takes the form property:operator_value where:

  • property is the field path you want to query,
  • operator (optional) defines the comparison (a bare : acts like =), and
  • value is the term you’re matching against.

So, for my case, I use these filters:

params={
    'limit': 'all',
    'filter': f'visibility:-paid+published_at:<{start_of_current_year}'
}
Enter fullscreen mode Exit fullscreen mode

Which represents not paid (-paid) and published before the start of the current year. In this case, I am getting all the records without pagination (limit = all).

Then for each post, I get its details:

# Get the latest version of the post to ensure we have the current updated_at
post_url = f"{api_url}/posts/{post['id']}/"
post_response = requests.get(post_url, headers=headers)
post_response.raise_for_status()
current_post = post_response.json()['posts'][0]
Enter fullscreen mode Exit fullscreen mode

This is because the update API below requires a updated_at field for collision detection. In my case, I don't want to change the update date.

And updating is done with a PUT method by which the body is:

'posts': [{
            'id': post['id'],
            'visibility': 'paid',
            'updated_at': current_post['updated_at']
        }]
Enter fullscreen mode Exit fullscreen mode

This updates all the previous posts to paid, keeping the update date.


🧱 Moving Public Posts This Year to “Members Only”

Another update that I wanted to do in bulk was to update some posts published this year to be members only and that were still public.

I needed to fix that, but only for current-year content, not the archive.

📁 Script: update_posts_member.py

🔍 Filter: published this year + visibility = "public"

🔄 Change: visibility → members

Why?

I offer a “members only” tier that’s free with signup, and I want this year’s posts to be accessible only after registration.

Here is the update posts function used in that script:

def update_posts_to_paid():
    """
    Update public posts from the current year to members-only status.

    This function:
    1. Retrieves all public posts published in the current year
    2. Updates each post's visibility to 'members'
    3. Maintains the post's updated_at timestamp to prevent conflicts

    Returns:
        int: Number of successfully updated posts

    Raises:
        Exception: If any error occurs during the update process
    """
    try:
        # Get authentication token and API URL
        token = get_ghost_token()
        api_url = get_ghost_api_url()

        # Set up headers
        headers = {
            'Authorization': f'Ghost {token}',
            'Content-Type': 'application/json',
            'Accept-Version': 'v5.0'
        }

        # Calculate the start of the current year
        current_year = datetime.now().year
        start_of_current_year = f"{current_year}-01-01T00:00:00Z"

        # Get posts from current year that are public
        posts_url = f"{api_url}/posts/"
        response = requests.get(
            posts_url,
            headers=headers,
            params={
                'limit': 'all',
                'filter': f'visibility:public+published_at:>={start_of_current_year}'
            }
        )
        response.raise_for_status()
        posts = response.json()['posts']

        logger.info(f"Found {len(posts)} posts from current year that are public")

        # Update each post to paid status
        updated_count = 0
        for post in posts:
            try:
                # Get the latest version of the post to ensure we have the current updated_at
                post_url = f"{api_url}/posts/{post['id']}/"
                post_response = requests.get(post_url, headers=headers)
                post_response.raise_for_status()
                current_post = post_response.json()['posts'][0]

                # Create the update data
                update_url = f"{api_url}/posts/{post['id']}/"
                update_data = {
                    'posts': [{
                        'id': post['id'],
                        'visibility': 'members',
                        'updated_at': current_post['updated_at']
                    }]
                }

                # Update the post to paid status
                update_response = requests.put(
                    update_url,
                    headers=headers,
                    json=update_data
                )
                update_response.raise_for_status()
                updated_count += 1
                logger.info(f"Updated post: {post['title']}")
            except Exception as e:
                logger.error(f"Failed to update post {post['title']}: {str(e)}")
                continue

        logger.info(f"Successfully updated {updated_count} posts to members status")
        return updated_count

    except Exception as e:
        logger.error(f"An error occurred: {str(e)}")
        raise
Enter fullscreen mode Exit fullscreen mode

For the filters, this time I used:

params={
    'limit': 'all',
    'filter': f'visibility:public+published_at:>={start_of_current_year}'
}
Enter fullscreen mode Exit fullscreen mode

Which represents public and published after (or at) the start of the current year. I am getting again all the records without pagination (limit = all).

Then, to update:

'posts': [{
    'id': post['id'],
    'visibility': 'members',
    'updated_at': current_post['updated_at']
}]
Enter fullscreen mode Exit fullscreen mode

This updates all the previous posts to members, keeping the update date. It is not shown here, but just like before, I get the updated_at from each post.


🔍 Searching Posts + Getting Direct Edit Links

This was a major pain point for me.

I often need to jump into Ghost to edit an old post, but Ghost has no “search posts by title” function.

📁 Script: post_search.py

🔎 Input: a search term

📋 Output: list of matching posts with direct edit URLs

Let's see the function that I used:

def search_posts(search_term):
    """
    Search for posts in Ghost that match the given search term in their title.

    This function:
    1. Searches for posts with titles containing the search term
    2. Displays detailed information about each matching post
    3. Provides both public URL and admin edit URL for each post

    Args:
        search_term (str): The term to search for in post titles

    Returns:
        list: List of matching post objects from the Ghost API

    Raises:
        Exception: If any error occurs during the search process
    """
    try:
        # Get authentication token and API URL
        token = get_ghost_token()
        api_url = get_ghost_api_url()

        # Set up headers
        headers = {
            'Authorization': f'Ghost {token}',
            'Content-Type': 'application/json',
            'Accept-Version': 'v5.0'
        }

        # Search for posts
        posts_url = f"{api_url}/posts/"
        response = requests.get(
            posts_url,
            headers=headers,
            params={
                'limit': 'all',
                'filter': f"title:~'{search_term}'"
            }
        )
        response.raise_for_status()
        posts = response.json()['posts']

        logger.info(f"Found {len(posts)} posts matching search term: '{search_term}'")

        # Display matching posts
        if posts:
            print("\nMatching Posts:")
            print("-" * 50)
            for post in posts:
                edit_url = get_edit_url(post['id'])
                print(f"Title: {post['title']}")
                print(f"Published: {post['published_at']}")
                print(f"Status: {post['status']}")
                print(f"Visibility: {post['visibility']}")
                print(f"URL: {post['url']}")
                print(f"Edit URL: {edit_url}")
                print("-" * 50)
        else:
            print(f"\nNo posts found matching: '{search_term}'")

        return posts

    except Exception as e:
        logger.error(f"An error occurred: {str(e)}")
        raise
Enter fullscreen mode Exit fullscreen mode

Similar to filtering the posts by visibility, the Ghost Admin API also allows filtering by title:

params={
        'limit': 'all',
        'filter': f"title:~'{search_term}'"
    }
Enter fullscreen mode Exit fullscreen mode

The ~ means that the title contains the search_term. So it doesn't need to be an exact match.

This will output something like this, in my case, searching for jinja2:

Enter search term: jinja2
2025-05-02 15:54:26,790 - INFO - Found 2 posts matching search term: 'jinja2'

Matching Posts:
--------------------------------------------------
Title: How to Build Dynamic Frontends with FastAPI and Jinja2
published: 2025-02-21T07:40:38.000Z
Status: published
Visibility: members
URL: https://developer-service.blog/how-to-build-dynamic-frontends-with-fastapi-and-jinja2/
Edit URL: https://xxxxxx.xxx/ghost/#/editor/post/69f91be4d20f7e/
--------------------------------------------------
Title: Creating a Web Application for Podcast Search using FastAPI, Jinja2, and Podcastindex.org
published: 2024-06-06T09:12:23.000Z
Status: published
Visibility: paid
URL: https://developer-service.blog/creating-a-web-application-for-podcast-search-using-fastapi-jinja2-and-podcastindex-org/
Edit URL: https://xxxxxx.xxx/ghost/#/editor/post/125171af2e15e59/
--------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

This script saves me endless time. I can search by partial title, copy the direct Ghost editor link, and jump straight into editing.


✅ Summary

With just three small scripts, I improved tremendously my entire editorial workflow:

Each script takes just a few seconds to run, and I now have more control over my Ghost posts.

If you’re running a content-heavy site on Ghost or offering tiered memberships, I highly recommend building your own admin tools like this.

Ghost’s API makes it easy, and Python makes it powerful.

GitHub repository for all the scripts: https://github.com/nunombispo/GhostContentAPI


Follow me on Twitter: https://twitter.com/DevAsService

Follow me on Instagram: https://www.instagram.com/devasservice/

Follow me on TikTok: https://www.tiktok.com/@devasservice

Follow me on YouTube: https://www.youtube.com/@DevAsService

Top comments (0)