3 # ljdump.py - livejournal archiver
4 # Greg Hewgill <greg@hewgill.com> http://hewgill.com
9 # This program reads the journal entries from a livejournal (or compatible)
10 # blog site and archives them in a subdirectory named after the journal name.
12 # The configuration is read from "ljdump.config". A sample configuration is
13 # provided in "ljdump.config.sample", which should be copied and then edited.
14 # The configuration settings are:
16 # server - The XMLRPC server URL. This should only need to be changed
17 # if you are dumping a journal that is livejournal-compatible
18 # but is not livejournal itself.
20 # username - The livejournal user name. A subdirectory will be created
21 # with this same name to store the journal entries.
23 # password - The account password. This password is never sent in the
24 # clear; the livejournal "challenge" password mechanism is used.
26 # This program may be run as often as needed to bring the backup copy up
27 # to date. Both new and updated items are downloaded.
29 # The community http://ljdump.livejournal.com has been set up for questions
34 # This software is provided 'as-is', without any express or implied
35 # warranty. In no event will the author be held liable for any damages
36 # arising from the use of this software.
38 # Permission is granted to anyone to use this software for any purpose,
39 # including commercial applications, and to alter it and redistribute it
40 # freely, subject to the following restrictions:
42 # 1. The origin of this software must not be misrepresented; you must not
43 # claim that you wrote the original software. If you use this software
44 # in a product, an acknowledgment in the product documentation would be
45 # appreciated but is not required.
46 # 2. Altered source versions must be plainly marked as such, and must not be
47 # misrepresented as being the original software.
48 # 3. This notice may not be removed or altered from any source distribution.
50 # Copyright (c) 2005-2009 Greg Hewgill
52 import codecs, md5, os, pickle, pprint, re, shutil, sys, urllib2, xml.dom.minidom, xmlrpclib
53 from xml.sax import saxutils
61 def calcchallenge(challenge, password):
62 return md5.new(challenge+md5.new(password).hexdigest()).hexdigest()
64 def flatresponse(response):
67 name = response.readline()
71 name = name[:len(name)-1]
72 value = response.readline()
74 value = value[:len(value)-1]
78 def getljsession(server, username, password):
79 r = urllib2.urlopen(server+"/interface/flat", "mode=getchallenge")
80 response = flatresponse(r)
82 r = urllib2.urlopen(server+"/interface/flat", "mode=sessiongenerate&user=%s&auth_method=challenge&auth_challenge=%s&auth_response=%s" % (username, response['challenge'], calcchallenge(response['challenge'], password)))
83 response = flatresponse(r)
85 return response['ljsession']
87 def dochallenge(server, params, password):
88 challenge = server.LJ.XMLRPC.getchallenge()
90 'auth_method': "challenge",
91 'auth_challenge': challenge['challenge'],
92 'auth_response': calcchallenge(challenge['challenge'], password)
96 def dumpelement(f, name, e):
97 f.write("<%s>\n" % name)
99 if isinstance(e[k], {}.__class__):
100 dumpelement(f, k, e[k])
102 s = unicode(str(e[k]), "UTF-8")
103 f.write("<%s>%s</%s>\n" % (k, saxutils.escape(s), k))
104 f.write("</%s>\n" % name)
106 def writedump(fn, event):
107 f = codecs.open(fn, "w", "UTF-8")
108 f.write("""<?xml version="1.0"?>\n""")
109 dumpelement(f, "event", event)
112 def writelast(username, lastsync, lastmaxid):
113 f = open("%s/.last" % username, "w")
114 f.write("%s\n" % lastsync)
115 f.write("%s\n" % lastmaxid)
118 def createxml(doc, name, map):
119 e = doc.createElement(name)
121 me = doc.createElement(k)
122 me.appendChild(doc.createTextNode(map[k]))
129 return e[0].firstChild.nodeValue
131 def ljdump(Server, Username, Password):
132 m = re.search("(.*)/interface/xmlrpc", Server)
136 print "Fetching journal entries for: %s" % Username
139 print "Created subdirectory: %s" % Username
143 ljsession = getljsession(Server, Username, Password)
145 server = xmlrpclib.ServerProxy(Server+"/interface/xmlrpc")
154 f = open("%s/.last" % Username, "r")
155 lastsync = f.readline()
156 if lastsync[-1] == '\n':
157 lastsync = lastsync[:len(lastsync)-1]
158 lastmaxid = f.readline()
159 if len(lastmaxid) > 0 and lastmaxid[-1] == '\n':
160 lastmaxid = lastmaxid[:len(lastmaxid)-1]
164 lastmaxid = int(lastmaxid)
168 origlastsync = lastsync
170 r = server.LJ.XMLRPC.login(dochallenge(server, {
171 'username': Username,
176 userpics = dict(zip(map(str, r['pickws']), r['pickwurls']))
177 userpics['*'] = r['defaultpicurl']
180 r = server.LJ.XMLRPC.syncitems(dochallenge(server, {
181 'username': Username,
183 'lastsync': lastsync,
186 if len(r['syncitems']) == 0:
188 for item in r['syncitems']:
189 if item['item'][0] == 'L':
190 print "Fetching journal entry %s (%s)" % (item['item'], item['action'])
192 e = server.LJ.XMLRPC.getevents(dochallenge(server, {
193 'username': Username,
196 'itemid': item['item'][2:],
199 writedump("%s/%s" % (Username, item['item']), e['events'][0])
202 print "Unexpected empty item: %s" % item['item']
204 except xmlrpclib.Fault, x:
205 print "Error getting item: %s" % item['item']
208 lastsync = item['time']
209 writelast(Username, lastsync, lastmaxid)
211 # The following code doesn't work because the server rejects our repeated calls.
212 # http://www.livejournal.com/doc/server/ljp.csp.xml-rpc.getevents.html
213 # contains the statement "You should use the syncitems selecttype in
214 # conjuntions [sic] with the syncitems protocol mode", but provides
215 # no other explanation about how these two function calls should
216 # interact. Therefore we just do the above slow one-at-a-time method.
219 # r = server.LJ.XMLRPC.getevents(dochallenge(server, {
220 # 'username': Username,
222 # 'selecttype': "syncitems",
223 # 'lastsync': lastsync,
226 # if len(r['events']) == 0:
228 # for item in r['events']:
229 # writedump("%s/L-%d" % (Username, item['itemid']), item)
231 # lastsync = item['eventtime']
233 print "Fetching journal comments for: %s" % Username
236 f = open("%s/comment.meta" % Username)
237 metacache = pickle.load(f)
243 f = open("%s/user.map" % Username)
244 usermap = pickle.load(f)
251 r = urllib2.urlopen(urllib2.Request(Server+"/export_comments.bml?get=comment_meta&startid=%d" % (maxid+1), headers = {'Cookie': "ljsession="+ljsession}))
252 meta = xml.dom.minidom.parse(r)
254 for c in meta.getElementsByTagName("comment"):
255 id = int(c.getAttribute("id"))
257 'posterid': c.getAttribute("posterid"),
258 'state': c.getAttribute("state"),
262 for u in meta.getElementsByTagName("usermap"):
263 usermap[u.getAttribute("id")] = u.getAttribute("user")
264 if maxid >= int(meta.getElementsByTagName("maxid")[0].firstChild.nodeValue):
267 f = open("%s/comment.meta" % Username, "w")
268 pickle.dump(metacache, f)
271 f = open("%s/user.map" % Username, "w")
272 pickle.dump(usermap, f)
275 print "Fetching userpics for: %s" % Username
276 f = open("%s/userpics.xml" % Username, "w")
277 print >>f, """<?xml version="1.0"?>"""
278 print >>f, "<userpics>"
280 print >>f, """<userpic keyword="%s" url="%s" />""" % (p, userpics[p])
281 pic = urllib2.urlopen(userpics[p])
282 ext = MimeExtensions.get(pic.info()["Content-Type"], "")
283 picfn = re.sub(r"[\/]", "_", p)
285 picfn = codecs.utf_8_decode(picfn)[0]
286 picf = open("%s/%s%s" % (Username, picfn, ext), "wb")
288 # for installations where the above utf_8_decode doesn't work
289 picfn = "".join([ord(x) < 128 and x or "?" for x in picfn])
290 picf = open("%s/%s%s" % (Username, picfn, ext), "wb")
291 shutil.copyfileobj(pic, picf)
294 print >>f, "</userpics>"
300 r = urllib2.urlopen(urllib2.Request(Server+"/export_comments.bml?get=comment_body&startid=%d" % (maxid+1), headers = {'Cookie': "ljsession="+ljsession}))
301 meta = xml.dom.minidom.parse(r)
303 for c in meta.getElementsByTagName("comment"):
304 id = int(c.getAttribute("id"))
305 jitemid = c.getAttribute("jitemid")
308 'parentid': c.getAttribute("parentid"),
309 'subject': gettext(c.getElementsByTagName("subject")),
310 'date': gettext(c.getElementsByTagName("date")),
311 'body': gettext(c.getElementsByTagName("body")),
312 'state': metacache[id]['state'],
314 if usermap.has_key(c.getAttribute("posterid")):
315 comment["user"] = usermap[c.getAttribute("posterid")]
317 entry = xml.dom.minidom.parse("%s/C-%s" % (Username, jitemid))
319 entry = xml.dom.minidom.getDOMImplementation().createDocument(None, "comments", None)
321 for d in entry.getElementsByTagName("comment"):
322 if int(d.getElementsByTagName("id")[0].firstChild.nodeValue) == id:
326 print "Warning: downloaded duplicate comment id %d in jitemid %s" % (id, jitemid)
328 entry.documentElement.appendChild(createxml(entry, "comment", comment))
329 f = codecs.open("%s/C-%s" % (Username, jitemid), "w", "UTF-8")
335 if maxid >= newmaxid:
340 writelast(Username, lastsync, lastmaxid)
343 print "%d new entries, %d new comments (since %s)" % (newentries, newcomments, origlastsync)
345 print "%d new entries, %d new comments" % (newentries, newcomments)
347 print "%d errors" % errors
349 if __name__ == "__main__":
350 config = xml.dom.minidom.parse("ljdump.config")
351 server = config.documentElement.getElementsByTagName("server")[0].childNodes[0].data
352 username = config.documentElement.getElementsByTagName("username")[0].childNodes[0].data
353 password = config.documentElement.getElementsByTagName("password")[0].childNodes[0].data
354 ljdump(server, username, password)