Converting blog to .md (.txt)

Vibe coding is the best part of AI, really.

There are times that I just want a simple script. Something useful to do something absolutely stupid-simple except that I don’t have the coding skills or the knowledge on which commands will get me what I want.

Some of the WordPress plugins I did for my gothic western were a bit more complex, but in no way would justify paying someone $10 annually for a subscription to “not quite do what I wanted it to do, but it was good enough for my needs” kind of thing. AI wrote those plugins in minutes flat for nothing.

I have no intention of taking work away from real coders, but having something to covert my old blog .xml files from sceadugenga.com (2019-2025) into a readable (or even printable) is not stealing money from anyone. It’s something that some simple coding can do, and more efficiently than both free and paid options out there offer.

I didn’t want to preserve these in a proprietary .pdf, .docx or .epub. I just wanted the posts as plain text or, preferably, markdown format (plain text with indicators like bold, HEADERS and italics/emphasis).

I have the old .xml files for the site, which are great for databases, but terrible for human eyes. You can save those as post backups from any WordPress installation.

Not everyone has access to plugins (paid WordPress-hosted is a higher paid tier and WordPress.com doesn’t have plugin access). But everyone can download their .xml files. The free plugins are wonky and I honestly think are purposefully gimped to encourage you to pay for the premium version when all I want is a text file.

So I asked Claude what it would take. The response?

  • Python 3.x installed on your PC (not an issue, more coding tools are always welcome and I really should learn Python some day)
  • The site’s .xml save files
  • A simple <1 MB script that runs on Python, here you go…

The process was a little more complex than just hitting “run”, and I had to get instructions on how to run it once I got set up with Python (download/install <1 minute), but it took 1 hour less than the free plugin to run it’s script on a localized version of the old blog (yes, you can run a blog offline; but that’s another post).

It was ready to read before I could reach for the mouse.

No Local (a program that allows you to run WP on your desktop). No plugin. Just a couple of clicks and BOOM. Three files (I have a lot of content in those old .xml files) readable as .md in the blink of an eye. Opening .md files in a text editor works just fine, the .md extension is so that markdown editors can find the files easier.

One day, I’ll give you the full stepwise process in case you’re not up on Python, but if you have working knowledge and want the script, here it is in less than 150 lines of code (after arrow on site, not sure how it renders in Reader — it probably skips hiding the code knowing how it works as a skinned RSS reader). Enjoy.

Code for converting WP .xml to .md
#!/usr/bin/env python3
"""
wxr_to_doc.py — Convert WordPress WXR export XML file(s) into one readable
Markdown document. Works entirely offline; no WordPress or database needed.

USAGE
    python3 wxr_to_doc.py export1.xml export2.xml -o my_writing.md

OPTIONS
    -o, --output       output filename (default: writing_compiled.md)
    --post-types       comma-separated post types to include (default: post)
                        (use e.g. "post,poem" if you export a custom post type)
    --status           comma-separated statuses to include (default: publish)
                        (use "publish,draft,private" to include everything)
    --sort             date (default) or title
    --categories       comma-separated category slugs/names to include
                        (omit to include all categories)

EXAMPLES
    # Basic: both export files, only published posts, chronological
    python3 wxr_to_doc.py posts1.xml posts2.xml -o compiled.md

    # Include drafts too, sorted alphabetically
    python3 wxr_to_doc.py posts1.xml posts2.xml --status publish,draft --sort title

    # Only a specific category (e.g. "poetry")
    python3 wxr_to_doc.py posts1.xml posts2.xml --categories poetry
"""

import argparse
import html
import re
from datetime import datetime
from xml.etree import ElementTree as ET

NS = {
    'content': 'http://purl.org/rss/1.0/modules/content/',
    'wp': 'http://wordpress.org/export/1.2/',
    'dc': 'http://purl.org/dc/elements/1.1/',
    'excerpt': 'http://wordpress.org/export/1.2/excerpt/',
}


def html_to_readable(raw_html):
    text = raw_html or ''
    # WXR often double-encodes entities
    text = html.unescape(html.unescape(text))
    # strip Gutenberg block comments
    text = re.sub(r'<!--\s*/?wp:[^>]*-->', '', text)
    # line breaks / paragraphs
    text = re.sub(r'<br\s*/?>', '\n', text, flags=re.I)
    text = re.sub(r'</p>\s*<p[^>]*>', '\n\n', text, flags=re.I)
    text = re.sub(r'</?p[^>]*>', '', text, flags=re.I)
    # basic emphasis, preserved as markdown
    text = re.sub(r'<(strong|b)[^>]*>(.*?)</\1>', r'**\2**', text, flags=re.I | re.S)
    text = re.sub(r'<(em|i)[^>]*>(.*?)</\1>', r'*\2*', text, flags=re.I | re.S)
    # blockquotes -> simple indent marker
    text = re.sub(r'<blockquote[^>]*>(.*?)</blockquote>', r'> \1', text, flags=re.I | re.S)
    # strip anything else
    text = re.sub(r'<[^>]+>', '', text)
    # collapse excess blank lines
    text = re.sub(r'\n{3,}', '\n\n', text)
    return text.strip()


def parse_wxr(path, post_types, statuses, categories_filter):
    tree = ET.parse(path)
    root = tree.getroot()
    channel = root.find('channel')
    items = []
    for item in channel.findall('item'):
        post_type = item.findtext('wp:post_type', default='', namespaces=NS)
        status = item.findtext('wp:status', default='', namespaces=NS)
        if post_type not in post_types or status not in statuses:
            continue

        cats = [c.text for c in item.findall('category') if c.get('domain') == 'category']
        if categories_filter and not (set(c.lower() for c in cats) & categories_filter):
            continue

        title = item.findtext('title', default='(untitled)') or '(untitled)'
        pub_date_raw = item.findtext('wp:post_date', default='', namespaces=NS)
        try:
            pub_date = datetime.strptime(pub_date_raw, '%Y-%m-%d %H:%M:%S')
        except ValueError:
            pub_date = None

        content_el = item.find('content:encoded', NS)
        content_raw = content_el.text if content_el is not None and content_el.text else ''

        items.append({
            'title': title.strip(),
            'date': pub_date,
            'content': html_to_readable(content_raw),
            'categories': cats,
        })
    return items


def main():
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument('files', nargs='+', help='WXR .xml export files')
    parser.add_argument('-o', '--output', default='writing_compiled.md')
    parser.add_argument('--post-types', default='post')
    parser.add_argument('--status', default='publish')
    parser.add_argument('--sort', choices=['date', 'title'], default='date')
    parser.add_argument('--categories', default='')
    args = parser.parse_args()

    post_types = set(t.strip() for t in args.post_types.split(','))
    statuses = set(s.strip() for s in args.status.split(','))
    categories_filter = set(c.strip().lower() for c in args.categories.split(',') if c.strip())

    all_items = []
    for f in args.files:
        all_items.extend(parse_wxr(f, post_types, statuses, categories_filter))

    if args.sort == 'date':
        all_items.sort(key=lambda x: x['date'] or datetime.min)
    else:
        all_items.sort(key=lambda x: x['title'].lower())

    with open(args.output, 'w', encoding='utf-8') as out:
        for it in all_items:
            out.write(f"# {it['title']}\n\n")
            meta_bits = []
            if it['date']:
                meta_bits.append(it['date'].strftime('%B %d, %Y'))
            if it['categories']:
                meta_bits.append(', '.join(it['categories']))
            if meta_bits:
                out.write(f"*{' — '.join(meta_bits)}*\n\n")
            out.write(it['content'])
            out.write('\n\n---\n\n')

    print(f"Wrote {len(all_items)} entries to {args.output}")


if __name__ == '__main__':
    main()

7 responses to “Converting blog to .md (.txt)”

  1. Ari Avatar

    Ahaha the things you can do by knowing a little bit of coding 😀 Using AI for the purpose of coding, is the best use I know of AI. As someone who can code, I have always been incredibly annoyed by how wordpressdotcom hides all coding capabilities to customise your site. I’ve been wanting to move my site to self hosting too, but I’m a little mad that I cannot export the template out. I spent so long designing my site, I don’t want to start from scratch T.T

    1. michael raven Avatar

      You can’t export the template? That seems odd, especially if WP is hosting (when they hosted my site, it was a simple redirect from xyz.wordpress.com to mysite.com). Nothing much changed aside from being able to turn off ads and use my personal domain.

      I used to be able to code and I’ve got the logic — just not the syntax. Mine is all WAY outdated (basic, Pascal, FORTRAN, R, old HTML and CSS, etc.) and I haven’t been in the mood to learn Python or others. That may change.

      It is so nice being outside of the coding paywall on this site. I can use plugins (including those I have AI code), and I can royally mess up my site if I want to by digging into the code and tweaking silly things that I can’t tweak on some servers.

      1. Ari Avatar

        Initially I was trying to buy a personal plan, but WordPress is dumb. I use a single use card for all online transactions, and so even after I clicked on “don’t save this card for future subscriptions”, they still tried to save it, and of course, my bank blocked that, and then wordpress were like, okay, we’re blocking all payments from your account. You can’t buy anything. I am so annoyed. I tried explaining this to the billing team, but they have not been responding at all. I even tried the forums, but nothing works. So then I was like, let’s move to self hosting, because I am beyond annoyed with WP’s service. But I can only export the posts, and contents, but none of the template designs :/ I’m so mad.

        1. michael raven Avatar

          I’ll look around later and see if we can help solve that template problem. I might be able to find a workaround.

          Bluehost used to be awesome for a host, but I had a really bad experience las time I tried to use them. I’m using Hostinger now, shared server space. While I can’t rave about them, they do a decent job.

          But first… template saving! 😉

          I’ll reach out direct and let you know what I find, if anything.

          1. Ari Avatar

            Ahh thanks a lot!! Don’t worry too much about it, if it annoys me a lot, and I have a weekend free, I might just end up migrating everything and designing things from scratch 😀 I’m quite good at designing my website, I even made my researcher page a couple of weeks ago (graphics and all), and it was the most fun I’ve had in a while. To be honest, I think it is way cooler than my poetry blog, because I could code everything from scratch, so it might not be that bad an idea to move and rebuild from scratch 😀

            Thanks for the rec on Hostinger, I will keep them in mind when I finally move 😀

  2. michael raven Avatar

    Holy crap. Between 2020 and 2023 I wrote 800,000+ words. Poor little pixels…

    1. Chico’s Mom Avatar

      Work that brain. That’s cool.

Leave a comment. Markdown permitted.

This site uses Akismet to reduce spam. Learn how your comment data is processed.