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()

Leave a comment. Markdown permitted. Comments are closed after three weeks.

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