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.