]> www.wagner.pp.ru Git - oss/ljdump.git/blob - ljdump.py
refactor implementation so ljdump can be called as a module
[oss/ljdump.git] / ljdump.py
1 #!/usr/bin/python
2 #
3 # ljdump.py - livejournal archiver
4 # Greg Hewgill <greg@hewgill.com> http://hewgill.com
5 # Version 1.3.2
6 #
7 # $Id$
8 #
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.
11 #
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:
15 #
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.
19 #
20 #   username - The livejournal user name. A subdirectory will be created
21 #              with this same name to store the journal entries.
22 #
23 #   password - The account password. This password is never sent in the
24 #              clear; the livejournal "challenge" password mechanism is used.
25 #
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.
28 #
29 # The community http://ljdump.livejournal.com has been set up for questions
30 # or comments.
31 #
32 # LICENSE
33 #
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.
37 #
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:
41 #
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.
49 #
50 # Copyright (c) 2005-2009 Greg Hewgill
51
52 import codecs, md5, os, pickle, pprint, re, shutil, sys, urllib2, xml.dom.minidom, xmlrpclib
53 from xml.sax import saxutils
54
55 MimeExtensions = {
56     "image/gif": ".gif",
57     "image/jpeg": ".jpg",
58     "image/png": ".png",
59 }
60
61 def calcchallenge(challenge, password):
62     return md5.new(challenge+md5.new(password).hexdigest()).hexdigest()
63
64 def flatresponse(response):
65     r = {}
66     while True:
67         name = response.readline()
68         if len(name) == 0:
69             break
70         if name[-1] == '\n':
71             name = name[:len(name)-1]
72         value = response.readline()
73         if value[-1] == '\n':
74             value = value[:len(value)-1]
75         r[name] = value
76     return r
77
78 def getljsession(server, username, password):
79     r = urllib2.urlopen(server+"/interface/flat", "mode=getchallenge")
80     response = flatresponse(r)
81     r.close()
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)
84     r.close()
85     return response['ljsession']
86
87 def dochallenge(server, params, password):
88     challenge = server.LJ.XMLRPC.getchallenge()
89     params.update({
90         'auth_method': "challenge",
91         'auth_challenge': challenge['challenge'],
92         'auth_response': calcchallenge(challenge['challenge'], password)
93     })
94     return params
95
96 def dumpelement(f, name, e):
97     f.write("<%s>\n" % name)
98     for k in e.keys():
99         if isinstance(e[k], {}.__class__):
100             dumpelement(f, k, e[k])
101         else:
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)
105
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)
110     f.close()
111
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)
116     f.close()
117
118 def createxml(doc, name, map):
119     e = doc.createElement(name)
120     for k in map.keys():
121         me = doc.createElement(k)
122         me.appendChild(doc.createTextNode(map[k]))
123         e.appendChild(me)
124     return e
125
126 def gettext(e):
127     if len(e) == 0:
128         return ""
129     return e[0].firstChild.nodeValue
130
131 def ljdump(Server, Username, Password):
132     m = re.search("(.*)/interface/xmlrpc", Server)
133     if m:
134         Server = m.group(1)
135
136     print "Fetching journal entries for: %s" % Username
137     try:
138         os.mkdir(Username)
139         print "Created subdirectory: %s" % Username
140     except:
141         pass
142
143     ljsession = getljsession(Server, Username, Password)
144
145     server = xmlrpclib.ServerProxy(Server+"/interface/xmlrpc")
146
147     newentries = 0
148     newcomments = 0
149     errors = 0
150
151     lastsync = ""
152     lastmaxid = 0
153     try:
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]
161         if lastmaxid == "":
162             lastmaxid = 0
163         else:
164             lastmaxid = int(lastmaxid)
165         f.close()
166     except:
167         pass
168     origlastsync = lastsync
169
170     r = server.LJ.XMLRPC.login(dochallenge(server, {
171         'username': Username,
172         'ver': 1,
173         'getpickws': 1,
174         'getpickwurls': 1,
175     }, Password))
176     userpics = dict(zip(map(str, r['pickws']), r['pickwurls']))
177     userpics['*'] = r['defaultpicurl']
178
179     while True:
180         r = server.LJ.XMLRPC.syncitems(dochallenge(server, {
181             'username': Username,
182             'ver': 1,
183             'lastsync': lastsync,
184         }, Password))
185         #pprint.pprint(r)
186         if len(r['syncitems']) == 0:
187             break
188         for item in r['syncitems']:
189             if item['item'][0] == 'L':
190                 print "Fetching journal entry %s (%s)" % (item['item'], item['action'])
191                 try:
192                     e = server.LJ.XMLRPC.getevents(dochallenge(server, {
193                         'username': Username,
194                         'ver': 1,
195                         'selecttype': "one",
196                         'itemid': item['item'][2:],
197                     }, Password))
198                     if e['events']:
199                         writedump("%s/%s" % (Username, item['item']), e['events'][0])
200                         newentries += 1
201                     else:
202                         print "Unexpected empty item: %s" % item['item']
203                         errors += 1
204                 except xmlrpclib.Fault, x:
205                     print "Error getting item: %s" % item['item']
206                     pprint.pprint(x)
207                     errors += 1
208             lastsync = item['time']
209             writelast(Username, lastsync, lastmaxid)
210
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.
217
218     #while True:
219     #    r = server.LJ.XMLRPC.getevents(dochallenge(server, {
220     #        'username': Username,
221     #        'ver': 1,
222     #        'selecttype': "syncitems",
223     #        'lastsync': lastsync,
224     #    }, Password))
225     #    pprint.pprint(r)
226     #    if len(r['events']) == 0:
227     #        break
228     #    for item in r['events']:
229     #        writedump("%s/L-%d" % (Username, item['itemid']), item)
230     #        newentries += 1
231     #        lastsync = item['eventtime']
232
233     print "Fetching journal comments for: %s" % Username
234
235     try:
236         f = open("%s/comment.meta" % Username)
237         metacache = pickle.load(f)
238         f.close()
239     except:
240         metacache = {}
241
242     try:
243         f = open("%s/user.map" % Username)
244         usermap = pickle.load(f)
245         f.close()
246     except:
247         usermap = {}
248
249     maxid = lastmaxid
250     while True:
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)
253         r.close()
254         for c in meta.getElementsByTagName("comment"):
255             id = int(c.getAttribute("id"))
256             metacache[id] = {
257                 'posterid': c.getAttribute("posterid"),
258                 'state': c.getAttribute("state"),
259             }
260             if id > maxid:
261                 maxid = id
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):
265             break
266
267     f = open("%s/comment.meta" % Username, "w")
268     pickle.dump(metacache, f)
269     f.close()
270
271     f = open("%s/user.map" % Username, "w")
272     pickle.dump(usermap, f)
273     f.close()
274
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>"
279     for p in 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)
284         try:
285             picfn = codecs.utf_8_decode(picfn)[0]
286             picf = open("%s/%s%s" % (Username, picfn, ext), "wb")
287         except:
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)
292         pic.close()
293         picf.close()
294     print >>f, "</userpics>"
295     f.close()
296
297     newmaxid = maxid
298     maxid = lastmaxid
299     while True:
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)
302         r.close()
303         for c in meta.getElementsByTagName("comment"):
304             id = int(c.getAttribute("id"))
305             jitemid = c.getAttribute("jitemid")
306             comment = {
307                 'id': str(id),
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'],
313             }
314             if usermap.has_key(c.getAttribute("posterid")):
315                 comment["user"] = usermap[c.getAttribute("posterid")]
316             try:
317                 entry = xml.dom.minidom.parse("%s/C-%s" % (Username, jitemid))
318             except:
319                 entry = xml.dom.minidom.getDOMImplementation().createDocument(None, "comments", None)
320             found = False
321             for d in entry.getElementsByTagName("comment"):
322                 if int(d.getElementsByTagName("id")[0].firstChild.nodeValue) == id:
323                     found = True
324                     break
325             if found:
326                 print "Warning: downloaded duplicate comment id %d in jitemid %s" % (id, jitemid)
327             else:
328                 entry.documentElement.appendChild(createxml(entry, "comment", comment))
329                 f = codecs.open("%s/C-%s" % (Username, jitemid), "w", "UTF-8")
330                 entry.writexml(f)
331                 f.close()
332                 newcomments += 1
333             if id > maxid:
334                 maxid = id
335         if maxid >= newmaxid:
336             break
337
338     lastmaxid = maxid
339
340     writelast(Username, lastsync, lastmaxid)
341
342     if origlastsync:
343         print "%d new entries, %d new comments (since %s)" % (newentries, newcomments, origlastsync)
344     else:
345         print "%d new entries, %d new comments" % (newentries, newcomments)
346     if errors > 0:
347         print "%d errors" % errors
348
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)