Building a Free, Open-Source Auto-Blogger: Generating and Publishing Content with AI

Building a Free, Open-Source Auto-Blogger: Generating and Publishing Content with AI
Photo by Austin Chan / Unsplash

Hey there, SaaS hackers! 👋 I'm sure you are here for a reason. We might have been in the same boat here and you also want a solution to boost your SEO by creating blogs. You might be tired of watching your competitors climb the search rankings while your awesome product sits on page 2 (or worse)? Well, buckle up, because we're about to change that.

In this tutorial, we're diving into the world of AI-powered content creation to boost your SEO game. We're not talking about churning out nonsense stuffed with keywords - nope, we're going to create a smart auto-blogger that pumps out content that's both search-engine friendly AND actually useful for your readers. The best of both worlds!

What's the Big Idea?

Picture this: You're focusing on other important things for your SaaS, and your auto-blogger is already hard at work, crafting blog posts tailored to your SaaS niche. By the time you wake up every morning, you've got fresh, SEO-optimized content ready to publish. Sounds like a dream, right? Well, we're about to make it a reality.

❤️
Here is a live example of using my auto-blogger to generate content: https://blog.dreamery.ai/

Here's what we're building:

  • An AI-powered content generator using Google's Gemma2 model (via Ollama)
  • A smart system to weave in your target keywords naturally
  • An auto-publishing setup to post directly to your Ghost blog

Why Should You Care?

  1. SEO on Autopilot: Keep your blog fresh with minimal effort, signalling to search engines that your site is active and relevant.
  2. Scale Your Content Strategy: Produce more content in less time, covering a wider range of topics related to your SaaS.
  3. Stay Ahead of the Curve: While your competitors are burning the midnight oil writing blogs, you'll be focusing on what really matters - improving your product.

What We'll Cover

  1. Setting the Stage: We'll get our hands dirty with the tech setup - don't worry, I'll walk you through it step-by-step.
  2. The Brain of the Operation: We'll dive into how to use AI to generate content that doesn't sound like it was written by a robot.
  3. Keyword Magic: Learn the secret sauce of integrating keywords without setting off Google's spam alarms.
  4. Publishing Like a Pro: We'll set up automatic publishing to Ghost, so your content goes live without you lifting a finger.
  5. Interlinks: We'll interlink our blog posts for better SEO.

Ready to give your SaaS the SEO boost it deserves? Let's jump in and start building your new secret weapon!

Let's create an auto-blogger

Before starting, you'll need a few things:

Install Ghost

docker run -d --name some-ghost -e NODE_ENV=development -e url=http://some-ghost.example.com ghost

  • Ollama: We don't need Ollama in the cloud. You can run this locally. Ollama works on CPU-only machines as well. If you have GPU then awesome else content generation will be a bit slow for you.

Install Ollama

Both of them are super easy to install. There is a one-command install script for both of them. You can either deploy both in your VM if you have one or for testing you can do it locally.

💬
If you need any help setting up Ghost or Ollama LMK. I can create another tutorial or guide you through it 🙂
  • Ghost Admin API Key: You'll need an API to interact with the ghost admin. You can easily grab it from your ghost instance.

Generate content using Ollama to generate content

This is the most interesting part. Let's pull the gemma2 model in Ollama.

ollama pull gemma2

This will download the gemma2 model for you. It's that simple. Now, you can start to interact with it if you want.

💡
You can use any LLM from the Ollama library btw. I am just using gemma2 because it's the most capable open-weight model for me right now.
ollama run gemma2

So easy right?

Now, let's use it to generate our blog content. You'll have to give some context to your SaaS.

CONTEXT = """<introduction about your SAAS platform here"""
💡
Your context should not be crazy long otherwise you will exhaust the context length. Make it brief and meaningful so that LLM can use it to generate content.

After context, let's work on the actual prompt for our LLM.

PROMPT_TEMPLATE = f"""Here is a context about <your-saas-name-here>: {CONTEXT}.

    Write an SEO-friendly catchy article about {content_theme} using dreamery in a news magazine style of more than 1000 words. Strictly follow the following JSON format and include all those keys as well for each section:
    ```{{
        "title": "The title of the blog post",
        "summary": "A brief summary of the blog post",
        "introduction": "The introduction paragraph(s)",
        "main_content": [{{
            "subheading": "Content for subheading",
            "content": "Content for subheading"
        }},
        {{
            "subheading": "Content for subheading",
            "content": "Content for subheading"
        }}],
        "Conclusion": "The conclusion paragraph(s)",
        "tags": ["tag1", "tag2", "tag3"]
    }}```
    """

As you can see, here we are asking our LLM to generate content for our SaaS given the context. We have also asked to follow the strict template so that we get a JSON output with the required keys. We will parse these keys in the later process to create our Lexical JSON content for our blog post.

content_theme here is an optional thing. I have different themes that I randomly pick to generate content based on that. For example: content for a professional headshot theme, a beauty professional theme, or a cartoon theme.

Cool beans 🫘

Now that we have a prompt, we'll now create a function to generate content based on that prompt.

# .env file

OLLAMA_API_URL = "http://localhost:11434/api/generate"  # assuming you have it running in your local
def generate_content(prompt, seed):
    payload = {
        "model": "gemma",
        "prompt": prompt,
        "stream": False,
        "options": {
            "seed": seed,
        },
    }

    response = requests.post(OLLAMA_API_URL, json=payload)
    if response.status_code == 200:
        return response.json()["response"]
    else:
        raise Exception(f"Failed to generate content: {response.text}")

And that's the final piece to generate content. 🥳

Now, we'll look into parsing and publishing this content.

Ghost Adming API clients to publish content

Interacting with ghost admin API is easy. Let's create some helper functions to retrieve and publish blog content.

# .env file

GHOST_API_URL = "https://<your-domain>/ghost/api/admin/"
GHOST_ADMIN_API_KEY = "<your-api-key>"
OLLAMA_API_URL = "http://localhost:11434/api/generate"  # assuming you have it running in your local

This is our env file with all the required data that we created earlier. We'll add more as we move on.

API key that we get from Ghost has 2 parts. We have to use them to generate a JWT token. Here is a function for that.

import jwt  # pip install pyjwt


def generate_jwt():
    id, secret = GHOST_ADMIN_API_KEY.split(":")

    iat = int(date.now().timestamp())

    header = {"alg": "HS256", "typ": "JWT", "kid": id}
    payload = {"iat": iat, "exp": iat + 5 * 60, "aud": "/admin/"}

    token = jwt.encode(
        payload, bytes.fromhex(secret), algorithm="HS256", headers=header
    )

    return token

Now, let's create a function to create a post. Here we will prepare a payload for the ghost /posts/ endpoint and also use that generate_jwt for token authentication.


def parse_json_content(content):
    try:
        return json.loads(content)
    except json.JSONDecodeError as e:
        logger.error(f"Failed to parse JSON content: {e}")
        return {}
        

def create_ghost_post(parsed_content, image, interlinking_map=None):
    url = f"{GHOST_API_URL}posts/"

    lexical_content = create_lexical_json(parsed_content, interlinking_map)

    data = {
        "posts": [
            {
                "title": parsed_content["title"],
                "lexical": lexical_content,
                "excerpt": parsed_content.get("summary", ""),
                "tags": [{"name": tag} for tag in parsed_content["tags"]],
                "status": "published",
                "authors": ["<your-author>"],
                "feature_image": image["images"][0]["url"],
            }
        ]
    }

    headers = {
        "Authorization": f"Ghost {generate_jwt()}",
        "Content-Type": "application/json",
        "Accept-Version": "v3.0",
    }

    response = requests.post(url, json=data, headers=headers)
    if response.status_code == 201:
        return response.json()
    else:
        raise Exception(f"Failed to create post: {response.text}")

Let's break it down. create_ghost_post is expecting a paresed_content, an image and an interlinking_map

  • parsed_content is a JSON content that we generate using LLM
  • image is the featured image that we want in our blog to make it pretty.
  • and interlinking_map is a mapper of internal blog post URLs.

We will revisit all these again.

Here, create_lexical_json function is the important one. This is the function that formats your blog content. It's going to be a huge block of code so I'll attach this at the end of this post.

Let's create a function to upload an image to Ghost.

def upload_image_to_ghost(image_path):
    url = f"{GHOST_API_URL}images/upload/"

    headers = {
        "Authorization": f"Ghost {generate_jwt()}",
        "Accept-Version": "v3.0",
    }

    filename = os.path.basename(image_path)
    with open(image_path, "rb") as image_file:
        files = {"file": (filename, image_file, "image/png")}
        data = {"purpose": "image", "ref": image_path}

        response = requests.post(url, headers=headers, files=files, data=data)
        response.raise_for_status()

        return response.json()

You'll need a folder of images that you want to feature in your blog post.

You can randomly pick an image from your folder and pass that path to this function like this:

random_image = random.choice(images)

Sweet. 🍬

That's all the pieces we need. 🎉 Now, you can arrange all this in your python file and run it however you want. I have created a cron job and scheduled the python code to run daily 😎 You can use it according to your preference.

Thanks for reading! *️⃣


Here is the snippet for creating Lexical JSON content. WARNING!!! this is very hacky but you should get the idea 😅

def create_lexical_json(parsed_content, interlinking_map=None):
    lexical_content = {
        "root": {
            "children": [],
            "direction": "ltr",
            "format": "",
            "indent": 0,
            "type": "root",
            "version": 1,
        }
    }

    # Add summary
    if "summary" in parsed_content:
        lexical_content["root"]["children"].append(
            {
                "children": [
                    {
                        "detail": 0,
                        "format": 0,
                        "mode": "normal",
                        "style": "",
                        "text": parsed_content["summary"],
                        "type": "extended-text",
                        "version": 1,
                    }
                ],
                "direction": "ltr",
                "format": "",
                "indent": 0,
                "type": "paragraph",
                "version": 1,
            }
        )

    # Add introduction
    if "introduction" in parsed_content:
        lexical_content["root"]["children"].extend(
            [
                {
                    "children": [
                        {
                            "detail": 0,
                            "format": 0,
                            "mode": "normal",
                            "style": "",
                            "text": "Introduction",
                            "type": "extended-text",
                            "version": 1,
                        }
                    ],
                    "direction": "ltr",
                    "format": "",
                    "indent": 0,
                    "type": "heading",
                    "tag": "h2",
                    "version": 1,
                },
                {
                    "children": [
                        {
                            "detail": 0,
                            "format": 0,
                            "mode": "normal",
                            "style": "",
                            "text": parsed_content["introduction"],
                            "type": "extended-text",
                            "version": 1,
                        }
                    ],
                    "direction": "ltr",
                    "format": "",
                    "indent": 0,
                    "type": "paragraph",
                    "version": 1,
                },
            ]
        )

    # Add main content
    for section in parsed_content.get("main_content", []):
        lexical_content["root"]["children"].extend(
            [
                {
                    "children": [
                        {
                            "detail": 0,
                            "format": 0,
                            "mode": "normal",
                            "style": "",
                            "text": section["subheading"],
                            "type": "extended-text",
                            "version": 1,
                        }
                    ],
                    "direction": "ltr",
                    "format": "",
                    "indent": 0,
                    "type": "heading",
                    "tag": "h2",
                    "version": 1,
                },
                {
                    "children": [
                        {
                            "detail": 0,
                            "format": 0,
                            "mode": "normal",
                            "style": "",
                            "text": section["content"],
                            "type": "extended-text",
                            "version": 1,
                        }
                    ],
                    "direction": "ltr",
                    "format": "",
                    "indent": 0,
                    "type": "paragraph",
                    "version": 1,
                },
            ]
        )

    # Add conclusion
    if "conclusion" in parsed_content:
        lexical_content["root"]["children"].extend(
            [
                {
                    "children": [
                        {
                            "detail": 0,
                            "format": 0,
                            "mode": "normal",
                            "style": "",
                            "text": "Conclusion",
                            "type": "extended-text",
                            "version": 1,
                        }
                    ],
                    "direction": "ltr",
                    "format": "",
                    "indent": 0,
                    "type": "heading",
                    "tag": "h2",
                    "version": 1,
                },
                {
                    "children": [
                        {
                            "detail": 0,
                            "format": 0,
                            "mode": "normal",
                            "style": "",
                            "text": parsed_content["conclusion"],
                            "type": "extended-text",
                            "version": 1,
                        }
                    ],
                    "direction": "ltr",
                    "format": "",
                    "indent": 0,
                    "type": "paragraph",
                    "version": 1,
                },
            ]
        )

    if interlinking_map:
        interlink_children = []
        for title, url in interlinking_map.items():
            c = 1
            interlink_children.append(
                {
                    "children": [
                        {
                            "children": [
                                {
                                    "detail": 0,
                                    "format": 0,
                                    "mode": "normal",
                                    "style": "",
                                    "text": title,
                                    "type": "text",
                                    "version": 1,
                                }
                            ],
                            "direction": "ltr",
                            "format": "",
                            "indent": 0,
                            "type": "link",
                            "version": 1,
                            "rel": "noopener",
                            "target": "_blank",
                            "url": url,
                        },
                    ],
                    "direction": "ltr",
                    "format": "",
                    "indent": 0,
                    "type": "listitem",
                    "version": 1,
                    "value": c,
                }
            )
            c += 1
        lexical_content["root"]["children"].extend(
            [
                {
                    "children": [
                        {
                            "detail": 0,
                            "format": 0,
                            "mode": "normal",
                            "style": "",
                            "text": "Read more about <your-saas-name>",
                            "type": "extended-text",
                            "version": 1,
                        }
                    ],
                    "direction": "ltr",
                    "format": "",
                    "indent": 0,
                    "type": "heading",
                    "tag": "h2",
                    "version": 1,
                },
                {
                    "children": interlink_children,
                    "direction": "ltr",
                    "format": "",
                    "indent": 0,
                    "type": "list",
                    "version": 1,
                    "start": 1,
                    "tag": "ol",
                },
            ]
        )

    return json.dumps(lexical_content)

This will format your blog content and add interlinks as well. You can view the live example here: https://blog.dreamery.ai/

Also please consider checking out my app Dreamery. Thanks! 🤗

Anish Shrestha

Anish Shrestha

I'm a certified TensorFlow developer and a software engineer specializing in building ai-based solutions, web applications, and everything in between.