Skip to content

Migrate Disqus comments to Utterances (in GitHub issues) with Python

When I replaced Jekyll and my Jekyll-ReadTheDocs theme with MkDocs and a blog-customised version of the Material for MkDocs theme, the URLs of my posts changed.

I was using Disqus for comments, and they provide a way to migrate threads from old URLs to new URLs. Unfortunately, this time it didn't work for some reason (I had already done it once in the past and it worked fine).

I've read more and more criticism about Disqus related to privacy, so I looked at a replacement. The Disqus thread migration was not working so it was the perfect occasion!

I've read a few webpages and got interested in Isso. Unfortunately again, I did not manage to install it on my Raspberry Pi.

So I went with a much simpler solution: Utterances. You basically enable a GitHub app on your repository, add a script in your posts pages, and voilà: your new comment section powered by GitHub issues.

I'm not completely satisfied because readers will need to log in with their GitHub account to comment (no anonymous comments), and you can't have nested discussions (well, just like in GitHub issues).

But this was really easy to setup 😄!

Now I just needed to migrate the Disqus comments into GitHub issues. To do this semi-automatically, I wrote the following script. "Semi-automatically" because I still had to write a "test" comment in each one of my posts so Utterances would initiate/create the issues. I guess it could be implemented in the script directly, but since I just had a dozen of posts, I took the easy/repetitive way.

The script¤


You'll have to create the initial issues before running the script! Serve your blog locally, and write a "test" comment in every post that has comments.

First, export your Disqus comments.

Then you'll need two Python libraries:

You'll also need to create a token on GitHub, with just the public repos access.

Write the following environment variables in a file, and source it.

export FILEPATH="comments.xml"
export USERNAME="yourUsername"
export TOKEN="yourToken"
export REPOSITORY="yourUsername/"
export BASE_URL=""

Now you can copy/paste and run this script:

import os
import time
import xmltodict
from github import Github

FILEPATH = os.environ["FILEPATH"]
USERNAME = os.environ["USERNAME"]
TOKEN = os.environ["TOKEN"]
BASE_URL = os.environ["BASE_URL"]

def disqus_to_github():
    g = Github(TOKEN)
    repo = g.get_repo(REPOSITORY)
    issues = repo.get_issues()

    with open(FILEPATH) as fd:
        data = xmltodict.parse(

    data = data["disqus"]

    threads = [dict(t) for t in data["thread"]]
    posts = sorted((dict(p) for p in data["post"]), key=lambda d: d["createdAt"])

    # only keep threads with comments
    twc_ids = set(p["thread"]["@dsq:id"] for p in posts)
    threads = {t["@dsq:id"]: t for t in threads if t["@dsq:id"] in twc_ids}

    # associate the thread to each post
    for post in posts:
        post["thread"] = threads[post["thread"]["@dsq:id"]]

    # associate the related GitHub issue to each thread
    # warning: the issues need to exist before you run this script!
    # write a "test" comment in each one of your post with comments
    # to make Utterances create the initial issues
    for thread in threads.values():
        for issue in issues:
            if issue.title == thread["link"].replace(BASE_URL, ""):
                thread["issue"] = issue

    # iterate on posts and create issues comments accordingly
    for i, post in enumerate(posts, 1):
        name = post["author"]["name"]
        user = post["author"].get("username")
        mention = " @" + user if user and not user.startswith("disqus_") else ""
        date = post["createdAt"]
        message = post["message"]
        issue = post["thread"]["issue"]
        body = f"*Original date: {date}*\n\n{message}"
        # don't add original author when it's you
        if user != USERNAME:
            body = f"*Original author:* **{name}{mention}**  \n{body}" 
        print(f"Posting {i}/{len(posts)} to issue {issue.number}    \r", end="")
        # prevent hitting rate limits!


if __name__ == "__main__":

I wrote this for a one-time, personal use only, so it could easily crash when you try it! Just use your Python skills and adapt it 😉