]> www.wagner.pp.ru Git - oss/ljdump.git/blob - ljdump.py
resume download if interrupted
[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.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 # LICENSE
30 #
31 # This software is provided 'as-is', without any express or implied
32 # warranty.  In no event will the author be held liable for any damages
33 # arising from the use of this software.
34 #
35 # Permission is granted to anyone to use this software for any purpose,
36 # including commercial applications, and to alter it and redistribute it
37 # freely, subject to the following restrictions:
38 #
39 # 1. The origin of this software must not be misrepresented; you must not
40 #    claim that you wrote the original software. If you use this software
41 #    in a product, an acknowledgment in the product documentation would be
42 #    appreciated but is not required.
43 # 2. Altered source versions must be plainly marked as such, and must not be
44 #    misrepresented as being the original software.
45 # 3. This notice may not be removed or altered from any source distribution.
46 #
47 # Copyright (c) 2005-2006 Greg Hewgill
48
49 import codecs, md5, os, pickle, pprint, re, shutil, sys, urllib2, xml.dom.minidom, xmlrpclib
50 from xml.sax import saxutils
51
52 MimeExtensions = {
53     "image/gif": ".gif",
54     "image/jpeg": ".jpg",
55     "image/png": ".png",
56 }
57
58 def calcchallenge(challenge, password):
59     return md5.new(challenge+md5.new(password).hexdigest()).hexdigest()
60
61 def flatresponse(response):
62     r = {}
63     while True:
64         name = response.readline()
65         if len(name) == 0:
66             break
67         if name[-1] == '\n':
68             name = name[:len(name)-1]
69         value = response.readline()
70         if value[-1] == '\n':
71             value = value[:len(value)-1]
72         r[name] = value
73     return r
74
75 def getljsession(username, password):
76     r = urllib2.urlopen(Server+"/interface/flat", "mode=getchallenge")
77     response = flatresponse(r)
78     r.close()
79     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)))
80     response = flatresponse(r)
81     r.close()
82     return response['ljsession']
83
84 def dochallenge(params, password):
85     challenge = server.LJ.XMLRPC.getchallenge()
86     params.update({
87         'auth_method': "challenge",
88         'auth_challenge': challenge['challenge'],
89         'auth_response': calcchallenge(challenge['challenge'], password)
90     })
91     return params
92
93 def dumpelement(f, name, e):
94     f.write("<%s>\n" % name)
95     for k in e.keys():
96         if isinstance(e[k], {}.__class__):
97             dumpelement(f, k, e[k])
98         else:
99             s = unicode(str(e[k]), "UTF-8")
100             f.write("<%s>%s</%s>\n" % (k, saxutils.escape(s), k))
101     f.write("</%s>\n" % name)
102
103 def writedump(fn, event):
104     f = codecs.open(fn, "w", "UTF-8")
105     f.write("""<?xml version="1.0"?>\n""")
106     dumpelement(f, "event", event)
107     f.close()
108
109 def writelast():
110     f = open("%s/.last" % Username, "w")
111     f.write("%s\n" % lastsync)
112     f.write("%s\n" % lastmaxid)
113     f.close()
114
115 def createxml(doc, name, map):
116     e = doc.createElement(name)
117     for k in map.keys():
118         me = doc.createElement(k)
119         me.appendChild(doc.createTextNode(map[k]))
120         e.appendChild(me)
121     return e
122
123 def gettext(e):
124     if len(e) == 0:
125         return ""
126     return e[0].firstChild.nodeValue
127
128 config = xml.dom.minidom.parse("ljdump.config")
129 Server = config.documentElement.getElementsByTagName("server")[0].childNodes[0].data
130 Username = config.documentElement.getElementsByTagName("username")[0].childNodes[0].data
131 Password = config.documentElement.getElementsByTagName("password")[0].childNodes[0].data
132
133 m = re.search("(.*)/interface/xmlrpc", Server)
134 if m:
135     Server = m.group(1)
136
137 print "Fetching journal entries for: %s" % Username
138 try:
139     os.mkdir(Username)
140     print "Created subdirectory: %s" % Username
141 except:
142     pass
143
144 ljsession = getljsession(Username, Password)
145
146 server = xmlrpclib.ServerProxy(Server+"/interface/xmlrpc")
147
148 newentries = 0
149 newcomments = 0
150 errors = 0
151
152 lastsync = ""
153 lastmaxid = 0
154 try:
155     f = open("%s/.last" % Username, "r")
156     lastsync = f.readline()
157     if lastsync[-1] == '\n':
158         lastsync = lastsync[:len(lastsync)-1]
159     lastmaxid = f.readline()
160     if len(lastmaxid) > 0 and lastmaxid[-1] == '\n':
161         lastmaxid = lastmaxid[:len(lastmaxid)-1]
162     if lastmaxid == "":
163         lastmaxid = 0
164     else:
165         lastmaxid = int(lastmaxid)
166     f.close()
167 except:
168     pass
169 origlastsync = lastsync
170
171 r = server.LJ.XMLRPC.login(dochallenge({
172     'username': Username,
173     'ver': 1,
174     'getpickws': 1,
175     'getpickwurls': 1,
176 }, Password))
177 userpics = dict(zip(map(str, r['pickws']), r['pickwurls']))
178 userpics['*'] = r['defaultpicurl']
179
180 while True:
181     r = server.LJ.XMLRPC.syncitems(dochallenge({
182         'username': Username,
183         'ver': 1,
184         'lastsync': lastsync,
185     }, Password))
186     #pprint.pprint(r)
187     if len(r['syncitems']) == 0:
188         break
189     for item in r['syncitems']:
190         if item['item'][0] == 'L':
191             print "Fetching journal entry %s (%s)" % (item['item'], item['action'])
192             try:
193                 e = server.LJ.XMLRPC.getevents(dochallenge({
194                     'username': Username,
195                     'ver': 1,
196                     'selecttype': "one",
197                     'itemid': item['item'][2:],
198                 }, Password))
199                 if e['events']:
200                     writedump("%s/%s" % (Username, item['item']), e['events'][0])
201                     newentries += 1
202                 else:
203                     print "Unexpected empty item: %s" % item['item']
204                     errors += 1
205             except xmlrpclib.Fault, x:
206                 print "Error getting item: %s" % item['item']
207                 pprint.pprint(x)
208                 errors += 1
209         lastsync = item['time']
210         writelast()
211
212 # The following code doesn't work because the server rejects our repeated calls.
213 # http://www.livejournal.com/doc/server/ljp.csp.xml-rpc.getevents.html
214 # contains the statement "You should use the syncitems selecttype in
215 # conjuntions [sic] with the syncitems protocol mode", but provides
216 # no other explanation about how these two function calls should
217 # interact. Therefore we just do the above slow one-at-a-time method.
218
219 #while True:
220 #    r = server.LJ.XMLRPC.getevents(dochallenge({
221 #        'username': Username,
222 #        'ver': 1,
223 #        'selecttype': "syncitems",
224 #        'lastsync': lastsync,
225 #    }, Password))
226 #    pprint.pprint(r)
227 #    if len(r['events']) == 0:
228 #        break
229 #    for item in r['events']:
230 #        writedump("%s/L-%d" % (Username, item['itemid']), item)
231 #        newentries += 1
232 #        lastsync = item['eventtime']
233
234 print "Fetching journal comments for: %s" % Username
235
236 try:
237     f = open("%s/comment.meta" % Username)
238     metacache = pickle.load(f)
239     f.close()
240 except:
241     metacache = {}
242
243 try:
244     f = open("%s/user.map" % Username)
245     usermap = pickle.load(f)
246     f.close()
247 except:
248     usermap = {}
249
250 maxid = lastmaxid
251 while True:
252     r = urllib2.urlopen(urllib2.Request(Server+"/export_comments.bml?get=comment_meta&startid=%d" % (maxid+1), headers = {'Cookie': "ljsession="+ljsession}))
253     meta = xml.dom.minidom.parse(r)
254     r.close()
255     for c in meta.getElementsByTagName("comment"):
256         id = int(c.getAttribute("id"))
257         metacache[id] = {
258             'posterid': c.getAttribute("posterid"),
259             'state': c.getAttribute("state"),
260         }
261         if id > maxid:
262             maxid = id
263     for u in meta.getElementsByTagName("usermap"):
264         usermap[u.getAttribute("id")] = u.getAttribute("user")
265     if maxid >= int(meta.getElementsByTagName("maxid")[0].firstChild.nodeValue):
266         break
267
268 f = open("%s/comment.meta" % Username, "w")
269 pickle.dump(metacache, f)
270 f.close()
271
272 f = open("%s/user.map" % Username, "w")
273 pickle.dump(usermap, f)
274 f.close()
275
276 print "Fetching userpics for: %s" % Username
277 f = open("%s/userpics.xml" % Username, "w")
278 print >>f, """<?xml version="1.0"?>"""
279 print >>f, "<userpics>"
280 for p in userpics:
281     print >>f, """<userpic keyword="%s" url="%s" />""" % (p, userpics[p])
282     pic = urllib2.urlopen(userpics[p])
283     ext = MimeExtensions.get(pic.info()["Content-Type"], "")
284     picf = open("%s/%s%s" % (Username, codecs.utf_8_decode(p)[0], ext), "wb")
285     shutil.copyfileobj(pic, picf)
286     pic.close()
287     picf.close()
288 print >>f, "</userpics>"
289 f.close()
290
291 newmaxid = maxid
292 maxid = lastmaxid
293 while True:
294     r = urllib2.urlopen(urllib2.Request(Server+"/export_comments.bml?get=comment_body&startid=%d" % (maxid+1), headers = {'Cookie': "ljsession="+ljsession}))
295     meta = xml.dom.minidom.parse(r)
296     r.close()
297     for c in meta.getElementsByTagName("comment"):
298         id = int(c.getAttribute("id"))
299         jitemid = c.getAttribute("jitemid")
300         comment = {
301             'id': str(id),
302             'parentid': c.getAttribute("parentid"),
303             'subject': gettext(c.getElementsByTagName("subject")),
304             'date': gettext(c.getElementsByTagName("date")),
305             'body': gettext(c.getElementsByTagName("body")),
306             'state': metacache[id]['state'],
307         }
308         if usermap.has_key(c.getAttribute("posterid")):
309             comment["user"] = usermap[c.getAttribute("posterid")]
310         try:
311             entry = xml.dom.minidom.parse("%s/C-%s" % (Username, jitemid))
312         except:
313             entry = xml.dom.minidom.getDOMImplementation().createDocument(None, "comments", None)
314         found = False
315         for d in entry.getElementsByTagName("comment"):
316             if int(d.getElementsByTagName("id")[0].firstChild.nodeValue) == id:
317                 found = True
318                 break
319         if found:
320             print "Warning: downloaded duplicate comment id %d in jitemid %s" % (id, jitemid)
321         else:
322             entry.documentElement.appendChild(createxml(entry, "comment", comment))
323             f = codecs.open("%s/C-%s" % (Username, jitemid), "w", "UTF-8")
324             entry.writexml(f)
325             f.close()
326             newcomments += 1
327         if id > maxid:
328             maxid = id
329     if maxid >= newmaxid:
330         break
331
332 lastmaxid = maxid
333
334 writelast()
335
336 if origlastsync:
337     print "%d new entries, %d new comments (since %s)" % (newentries, newcomments, origlastsync)
338 else:
339     print "%d new entries, %d new comments" % (newentries, newcomments)
340 if errors > 0:
341     print "%d errors" % errors