Blogging from BBEdit, redux
June 22, 2014 at 10:41 PM by Dr. Drang
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,
Publish Post
, and I still have it saved as a BBEdit Text Filter, accessible through the 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
- The URL of the blog’s
xmlrpc.php
file. This is defined in Line 35. - The name of the MetaWeblog API call. This will be either
metaWeblog.newPost
,metaWeblog.editPost
, ormetaWeblog.getPost
, depending on where we are in the script. - A tuple of parameters that make up the payload of information that’s POSTed in the MetaWeblog API call.
What xmlrpc
does is
- Wrap the parameters and the API call up in an XML structure using the
xmlrpclib.dumps
convenience function. - POST the XML structure to the WordPress
xmlrpc.php
usingrequests
. - Unwrap the content of the XML response into a Python dictionary using the
xmlrpclib.loads
convenience function. This is whatxmlrpc
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.
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.