Blogging from BBEdit, redux

I’ve been having blog problems the past couple of weeks. My script for publishing from BBEdit has been working fine when publishing new posts, but it’s been throwing errors when updating posts after editing. I’m not sure what has changed. I did update my WordPress installation recently, but I’m also suspicious of new configurations my webhost may have implemented. Whatever the cause of the problem, I decided to rewrite the script to change how it used the XML-RPC MetaWeblog API. It seems to be working consistently now.

The errors using the old script (initial version described here, slight improvement described here) traced back to the server returning an HTTP 302 code when accessed through Python’s xmlrpclib module. I wondered if the problem was due to the underlying urllib calls. I decided to do my own XML-RPC calls via Kenneth Reitz’s requests module, which I knew would follow redirects.

It took a bit of messing around, but with the help of Dave Winer’s MetaWeblog RFC, this article at OpenGroupware, and this one at O’Reilly, I finally pieced together all the parts and got the script working. Despite being written at a distinctly lower level than the original,

1 it’s only about 20 lines longer. It’s still called Publish Post, and I still have it saved as a BBEdit Text Filter, accessible through the Text‣Apply Text Filter‣Publish Post menu item. Here’s the source code, anonymized in the places you’d expect:

  1  #!/usr/bin/python
  2  # -*- coding: utf-8 -*-
  3  
  4  import xmlrpclib
  5  import sys
  6  from datetime import datetime, timedelta
  7  import time
  8  import pytz
  9  import keyring
 10  import subprocess
 11  import tweepy
 12  import requests
 13  
 14  '''
 15  Take text from standard input in the format
 16  
 17    Title: Blog post title
 18    Keywords: key1, key2, etc
 19  
 20    Body of post after the first blank line.
 21  
 22  and publish it to my WordPress blog. Return in standard output
 23  the same post after publishing. It will then have more header
 24  fields (see hFields for the list) and can be edited and re-
 25  published again and again.
 26  
 27  The goal is to work the same way TextMate's Blogging Bundle does
 28  but with fewer initial headers.
 29  '''
 30  
 31  
 32  ####################### Parameters ########################
 33  
 34  # The blog's XMLRPC URL and username.
 35  url = 'http://leancrew.com/path/to/xmlrpc.php'
 36  user = 'username'
 37  
 38  # Time zones. WP is trustworthy only in UTC.
 39  utc = pytz.utc
 40  myTZ = pytz.timezone('US/Central')
 41  
 42  # The header fields and their metaWeblog synonyms.
 43  hFields = [ 'Title', 'Keywords', 'Date', 'Post',
 44              'Slug', 'Link', 'Status', 'Comments' ]
 45  wpFields = [ 'title', 'mt_keywords', 'date_created_gmt',  'postid',
 46               'wp_slug', 'link', 'post_status', 'mt_allow_comments' ]
 47  h2wp = dict(zip(hFields, wpFields))
 48  
 49  
 50  ######################## Functions ########################
 51  
 52  def makeContent(header, body):
 53    "Make the content dict from the header dict."
 54    content = {}
 55    for k, v in header.items():
 56      content.update({h2wp[k]: v})
 57    content.update(description=body)
 58    return content
 59  
 60  def xmlrpc(url, call, params):
 61    "Send an XMLRPC request and return the response as a dictionary."
 62    payload = xmlrpclib.dumps(params, call)
 63    resp = requests.post(url, data=payload)
 64    return xmlrpclib.loads(resp.content)[0][0]
 65  
 66  def tweetlink(text, url):
 67    "Tweet a link to the given URL. Return the URL to the tweet."
 68  
 69    # Authorize Twitter and establish a connection.
 70    auth = tweepy.OAuthHandler('theAPIKey',
 71             'theAPISecret')
 72    auth.set_access_token('theAccessToken',
 73             'theAccessTokenSecret')
 74    api = tweepy.API(auth)
 75  
 76    # How long will the shortened URL to the post be?
 77    short_length = api.configuration()['short_url_length']
 78    
 79    # Don't include the snowman if the tweet is about Afghanistan.
 80    if "Afghanistan" in text:
 81      prefix = u''
 82    else:
 83      prefix = u'⛄ '
 84  
 85    # Construct the tweet.
 86    max_text = 140 - short_length - (len(prefix) + 1)
 87    if len(text) > max_text:
 88      text = prefix + text[:max_text-1] + u'…'
 89    else:
 90      text = prefix + text
 91    tweet = '''{}
 92  {}'''.format(text.encode('utf-8'), url)
 93  
 94    # Send the tweet.
 95    out = api.update_status(tweet)
 96    return 'https://twitter.com/drdrang/status/%s' % out.id_str
 97  
 98  
 99  ###################### Main program #######################
100  
101  # Read and parse the source.
102  source = sys.stdin.read()
103  header, body = source.split('\n\n', 1)
104  header = dict( [ x.split(': ', 1) for x in header.split('\n') ])
105  
106  # The publication date may or may not be in the header.
107  if 'Date' in header:
108    # Get the date from the string in the header.
109    dt = datetime.strptime(header['Date'], "%Y-%m-%d %H:%M:%S")
110    dt = myTZ.localize(dt)
111    header['Date'] = xmlrpclib.DateTime(dt.astimezone(utc))
112    # Articles for later posting shouldn't be tweeted.
113    tweetit = False
114  else:
115    # Use the current date and time.
116    dt = myTZ.localize(datetime.now())
117    header.update({'Date': xmlrpclib.DateTime(dt.astimezone(utc))})
118    # Articles for posting now should be tweeted.
119    tweetit = True
120  
121  # Get the password from Keychain.
122  pw = keyring.get_password(url, user)
123  
124  # Connect and upload the post.
125  blog = xmlrpclib.Server(url)
126  
127  # It's either a new post or an old one that's been revised.
128  if 'Post' in header:
129    # Revising an old post.
130    postID = int(header['Post'])
131    del header['Post']
132    content = makeContent(header, body)
133    
134    # Upload the edited post.
135    params = (postID, user, pw, content, True)
136    unused = xmlrpc(url, 'metaWeblog.editPost', params)
137    
138    # Download the edited post.
139    params = (postID, user, pw)
140    post = xmlrpc(url, 'metaWeblog.getPost', params)
141    
142  else:
143    # Publishing a new post.
144    content = makeContent(header, body)
145    
146    # Upload the new post and get its ID.
147    params = (0, user, pw, content, True)
148    postID = xmlrpc(url, 'metaWeblog.newPost', params)
149    postID = int(postID)
150    
151    # Download the new post.
152    params = (postID, user, pw)
153    post = xmlrpc(url, 'metaWeblog.getPost', params)
154    
155    if tweetit:
156      tweetURL = tweetlink(post[h2wp['Title']], post[h2wp['Link']])
157  
158  # Print the header and body.
159  header = ''
160  for f in hFields:
161    if f == 'Date':
162      # Change the date from UTC to local and from DateTime to string.
163      dt = datetime.strptime(post[h2wp[f]].value, "%Y%m%dT%H:%M:%S")
164      dt = utc.localize(dt).astimezone(myTZ)
165      header += "%s: %s\n" % (f, dt.strftime("%Y-%m-%d %H:%M:%S"))
166    else:
167      header += "%s: %s\n" % (f, post[h2wp[f]])
168  print header.encode('utf8')
169  print
170  print post['description'].encode('utf8')
171  
172  # Open the published post in the default browser after a delay.
173  time.sleep(3)
174  subprocess.call(['open', post['link']])

I’m not going to explain how I use the script or most of its internal workings. That was covered in the two earlier blog posts. What I do want to talk about is the new xmlrpc function defined in Lines 60–64 and used four times in Lines 128–156.

The three arguments to xmlrpc are

  1. The URL of the blog’s xmlrpc.php file. This is defined in Line 35.
  2. The name of the MetaWeblog API call. This will be either metaWeblog.newPost, metaWeblog.editPost, or metaWeblog.getPost, depending on where we are in the script.
  3. A tuple of parameters that make up the payload of information that’s POSTed in the MetaWeblog API call.

What xmlrpc does is

  1. Wrap the parameters and the API call up in an XML structure using the xmlrpclib.dumps convenience function.
  2. POST the XML structure to the WordPress xmlrpc.php using requests.
  3. Unwrap the content of the XML response into a Python dictionary using the xmlrpclib.loads convenience function. This is what xmlrpc returns.

This is, I believe, pretty much what method calls to an xmlrpclib.ServerProxy object do, except that this function takes advantage of the magic Reitz built into requests. Once I had xmlrpc written, it was easy to replace the ServerProxy method calls that had been triggering the 302 errors.

2

It’s been a week of rewriting old scripts and workflows: photo mapping, expense reports, invoicing emails, Markdown table formatting, and now blog posting. Maintenance is important, but I’d like to move on to something new.


  1. You might say it’s closer to the MetaWeblog. 

  2. I am by no means a web programmer, but here I am playing one on the internet. Frightening.