Skip to: Content | Sidebar | Footer

danbooru.py

danbooru.py is a Python script-slash-tool for interfacing with Danbooru. It allows you to batch-download images from their official API. It does not use screen scraping and, as far as I can tell, does not violate their terms of service. However, Danbooru has been limiting the API access as of late, so a lot of the times the script doesn’t work. It’s supposed to be because of the load, I don’t know.

Download danbooru.py.

Due to the way the script is made, it is easy to make it work with other sites that use the same software as Danbooru, like moe.imuoto. You can download a quick and dirty version of the script that does so: moe.py. Eventually, I’ll pretty these things up and release a proper version and support more sites.

Code Preview

This is a slightly outdated preview of what the code looks like. I’m including it because it looks pretty, and also because there is documentation in it.

1 #!/usr/bin/env python
2
3 '''
4 danbooru.py (http://untu.ms/danbooru/)
5 ======================================
6 A content retrieval tool for danbooru (http://danbooru.donmai.us/). The
7 requirements are Python 2.5 (http://python.org/) and a little console-fu.
8
9 usage examples
10 ==============
11 * danbooru.py negima “cat ears”
12 Download content tagged negima and cat_ears to the default folder
13 (negima+cat_ears)
14 * danbooru.py -x himm -r safe “sawatari izumi”
15 Download content tagged sawatari_izumi and rated safe (-r or –rating) to
16 a folder named himm (-f or –folder)
17 * danbooru.py -l 50 gif
18 Download content tagged as gif, limiting it to 50 posts (-l or –limit)
19 * danbooru.py -i -n -s 8 flash
20 Download content tagged flash, ignoring the youngest local file (or last
21 id, refers to the -i or –no-last-id option), ignore whether the file
22 exists in the local database (-n or –no-db), and use server 8 (-s or
23 –server option, use -L or –list to see a list of available servers)
24 * danbooru.py -c * -x *
25 Catalogue (-c or –catalogue) and rename (-x or –fix) all files in all
26 subfolders in the current path
27 * danbooru.py -h
28 View a list of available commands
29
30 version history
31 ===============
32 * 0.2: 09.01.2007
33 * 0.1: 01.01.2007
34
35 copyright
36 =========
37 danbooru.py is made by Reinis Ivanovs (dabas@untu.ms) and is released
38 to the public domain.
39 '''
40
41 import re
42 import os
43 import urllib
44 import shelve
45 import sqlite3
46 import pickle
47
48 from glob import glob, iglob
49 from hashlib import md5
50 from sys import platform, stderr
51 from time import time
52 from xml.dom import minidom
53
54 # time.clock is more granual than time.time on win32
55 if platform == 'win32':
56 from time import clock as xtime, sleep
57 else:
58 from time import time as xtime, sleep
59
60 # A tender age
61 __version__ = '0.2'
62 __build__ = '119'
63
64 case = lambda count, word: word if count == 1 else word + 's'
65 cases = lambda count, singular, plural: singular if count == 1 else plural
66
67
68 # Identify as danbooru.py/0.x (change this if you want to go ninja)
69 class Opener(urllib.FancyURLopener):
70 version = 'danbooru.py/%s' % (__version__,)
71 urllib._urlopener = Opener()
72
73
74 class ServerIdError(KeyError):
75 '''No such server ID'''
76
77
78 class Robot(dict):
79 api_url = 'http://danbooru.donmai.us/api/'
80 posts_path = 'find_posts?tags=%(tags)s%(last_id)s%(rating)s&limit=\
81 %(limit)d&offset=%(offset)d'
82 last_id = '+after_id:%d'
83 rating_path = '+rating:%s'
84 servers_path = 'find_servers'
85 md5_path = 'find_posts?md5=%s'
86 settings_filename = os.path.join(os.path.expanduser('~'), '.danboorudata')
87 db_filename = os.path.join(os.path.expanduser('~'), '.danboorudb')
88 namepattern = re.compile(r'(?:\d+_)?([a-f\d]{32})')
89 idpattern = re.compile(r'(\d+)_[a-f\d]{32}')
90 logfile = 'error.log'
91
92 def __init__(self, args, limit, offset, **kwargs):
93 for key, value in kwargs.iteritems():
94 self[key] = value
95 self.end = lambda text, start: '%s (%.2fs)' % (text, time()-start)
96 self.tags = self.parse_tags(args)
97 self.settings = self.load_settings()
98 self.servers = self.load_servers()
99 self.db, self.cur = self.load_db()
100 self.folder = self.tags
101 self.limit = limit
102 self.offset = offset
103 self.dl = Downloader()
104
105 def get_last_id(self, pathname):
106 '''Get the youngest file by its danbooru id'''
107 if self['refresh']:
108 return ''
109 filenames = self.get_filenames(pathname)
110 filenames = sorted(filenames)[::-1]
111 for item in filenames:
112 folder, name, ext = self.split_path(item)
113 match = re.match(self.idpattern, name)
114 if match:
115 id = int(match.group(1).lstrip('0'))
116 break
117 else: id = 1
118 return self.last_id % (int(id),)
119
120 def error(self, message):
121 print >> stderr, 'Error: %s' % (message,)
122
123 def retrieve_content(self):
124 '''Start downloading'''
125 print 'Downloading to %s…' % (self.folder,)
126 if not os.path.exists(self.folder):
127 os.mkdir(self.folder)
128 last_id = self.get_last_id(self.folder)
129 step, limit, offset = 100, self.limit, self.offset
130 for i in xrange(offset, limit, step):
131 j = i+step if i+step < limit else limit
132 params = { 'tags': self.tags, 'last_id': last_id, 'limit': j,
133 'offset': i, 'rating': self.rating_path % self['rating'] \
134 if self['rating'] else ''}
135 path, start = self.posts_path % params, time()
136 url = self.api_url+path
137 print 'API:', path+'…',
138 data = self.get_data(url, 'post', 'id')
139 print self.end('done', start)
140 if self['nodb'] or not len(data):
141 print '%d posts returned' % (len(data),)
142 if not len(data): break
143 else:
144 before = len(data)
145 self.filter_data(data)
146 values = (before, case(before, 'post'),
147 len(data), cases(len(data), 'wasn\'t', 'weren\'t'))
148 print '%d %s returned, %d %s in the local database' % values
149 if self['simulate']: continue
150 if len(data):
151 for key, value in data.iteritems():
152 self.get_post(key, value)
153 self.update_db(data)
154 self.db.commit()
155 try:
156 if before < step: break
157 except UnboundLocalError, e:
158 print e
159 else:
160 print 'Post limit (%d) met' % (limit,)
161 if not glob(os.path.join(self.folder, '*')):
162 print '%s is empty: removing' % (self.folder,)
163 os.rmdir(self.folder)
164
165 def get_post(self, id, post):
166 '''Download an individual post'''
167 filename = post['file_name']
168 # Figure out the local name (id is padded with zeroes)
169 localname = os.path.join(self.folder, '%07d_%s' % (id, filename))
170 if os.path.exists(localname):
171 self.error('File already exists')
172 return
173 server = self.servers[self.server]
174 server = 'http://' + server['host'] + server['path']
175 url = server + '/'.join((filename[0:2], filename[2:4], filename))
176 print url
177 print self.dl.retrieve(url, localname, self.exit), \
178 'KiB retrieved in %s' % (self.folder,)
179
180 def filter_data(self, data):
181 '''Filter out the data that already exists in the local db'''
182 values = '”,”'.join([str(key) for key in data.keys()])
183 query = self.cur.execute(self.by_id_command % values)
184 for row in query:
185 id, = row
186 if id in data:
187 del data[id]
188 return data
189
190 def log(self, message):
191 '''Unused'''
192 print >> open(self.logfile, 'a+'), message
193
194 def use_server(self, id):
195 '''Set the server for this instance to use'''
196 if id not in self.servers:
197 raise ServerIdError, id
198 self.server = id
199
200 def load_servers(self):
201 '''Load the servers from stored data'''
202 if 'servers' in self.settings:
203 servers = self.settings['servers']
204 else:
205 servers = self.update_servers()
206 return servers
207
208 # TODO: remove redundant parts and use the get_data() method instead
209 def update_servers(self):
210 '''Download and parse the server list from the api'''
211 if 'servers_fresh' in dir(self):
212 return self.servers
213 print 'Updating servers list…',
214 start = time()
215 url = self.api_url + self.servers_path
216 data = minidom.parse(urllib.urlopen(url))
217 results = {}
218 for server in data.getElementsByTagName('server'):
219 attributes = dict(server.attributes.items())
220 results[int(attributes.pop('id'))] = attributes
221 if not len(results):
222 print self.end('done', start)
223 self.error('danbooro seems to be down')
224 self.exit()
225 data.unlink()
226 self.servers_fresh = True
227 self.save_settings(servers=results)
228 print self.end('done', start)
229 return results
230
231 def list_servers(self):
232 '''Print servers'''
233 print 'Listing servers…'
234 row = lambda id, host: '%s %s' % (str(id).rjust(2), host)
235 print row('ID', 'Host')
236 for id in self.servers:
237 print row(id, self.servers[id]['host']), '[default]' \
238 if id == self.settings['default'] else ''
239
240 def load_settings(self):
241 '''Connect to the persistent settings'''
242 return shelve.open(self.settings_filename)
243
244 def save_settings(self, **kwargs):
245 '''Save and flush settings'''
246 for key in kwargs:
247 self.settings[key] = kwargs[key]
248 self.settings.sync()
249
250 def get_data(self, url, elementname, keyname):
251 '''Fetch and parse data from the api (would be many lines longer if \
252 this had to be actually spidered)'''
253 data = minidom.parse(urllib.urlopen(url))
254 results = {}
255 for server in data.getElementsByTagName(elementname):
256 attributes = dict(server.attributes.items())
257 results[int(attributes.pop(keyname))] = attributes
258 data.unlink()
259 return results
260
261 def get_serverlist(self):
262 url, start = self.api_url + self.servers_path, time()
263 print 'Getting servers list…',
264 results = self.get_data(url, 'server', 'id')
265 print self.end(start)
266 return results
267
268 def get_content_data(self, tags=None, limit=None, offset=None, hashes=None):
269 if tags and limit and offset:
270 url = self.api_url + self.posts_path % (tags, limit, offset)
271 count = limit - offset
272 elif hashes:
273 url = self.api_url + self.md5_path % ','.join(hashes)
274 count = len(hashes)
275 print 'Getting content data for %d %s…' % (count, case(count, 'file')),
276 start = time()
277 results = self.get_data(url, 'post', 'id')
278 print self.end('done', start)
279 if len(results) < count:
280 missing = count - len(results)
281 print '%d %s not found' % (missing, case(missing, 'file'))
282 return results
283
284 def parse_tags(self, args):
285 '''Parse script arguments'''
286 tags = [urllib.quote(item.replace(' ', '_')).replace('%2B', '+') \
287 for item in args]
288 tags = '+'.join(tags)
289 return tags
290
291 def exit(self):
292 '''Say bye and report db changes'''
293 changes = self.db.total_changes
294 if changes:
295 print '%d %s to the local database in this session' % \
296 (changes, case(changes, 'change'))
297 print 'Bye~!'
298 exit()
299
300 def load_db(self):
301 '''Connect to the sqlite db'''
302 self.init_db_command ='''CREATE TABLE IF NOT EXISTS content \
303 (id INTEGER PRIMARY KEY, md5 TEXT, tags TEXT, misc BLOB);'''
304 self.update_db_command ='''INSERT OR IGNORE into content \
305 (id, md5, tags, misc) values (%d, “%s“, “%s“, “%s“);'''
306 self.by_md5_command ='''SELECT md5, id FROM content \
307 WHERE md5 IN (“%s“);'''
308 self.by_id_command ='''SELECT id FROM content WHERE id IN (“%s“);'''
309 db = sqlite3.connect(self.db_filename)
310 db.text_factory = lambda text: unicode(text, 'utf-8', 'ignore')
311 cur = db.cursor()
312 cur.execute(self.init_db_command)
313 return db, cur
314
315 def hash_in_filename(self, filename):
316 '''Try to avoid hashing the file'''
317 name, ext = os.path.splitext(os.path.basename(filename))
318 results = re.search(self.namepattern, name)
319 return results.groups()[0] if results else None
320
321 def filter_hashes(self, hashes):
322 '''Remove hashes that exist in the local database'''
323 values = '”,”'.join(hashes.values())
324 query = self.cur.execute(self.by_md5_command % values)
325 for hash in query:
326 hash, id = hash
327 for key, value in hashes.copy().iteritems():
328 if hash != value: continue
329 del hashes[key]
330 return hashes
331
332 def get_hashes(self, names, source, filter=True):
333 '''Get hashes for files in a path'''
334 print 'Getting hashes for %d %s in %s…' % \
335 (len(names), case(len(names), 'file'), source),
336 results, start = {}, time()
337 for item in names:
338 hash = self.hash_in_filename(item) \
339 or md5(open(item, 'rb').read()).hexdigest()
340 results[item] = hash
341 if filter:
342 results = self.filter_hashes(results)
343 print self.end('done', start)
344 return results
345
346 def catalogue_content(self, pathname):
347 '''Add files to the local database'''
348 print 'Starting to catalogue %s…' % (pathname,)
349 filenames = self.get_filenames(pathname)
350 hashes = self.get_hashes(filenames, pathname)
351 message = '%d of %d files already in local database'
352 print message % (len(filenames)-len(hashes), len(filenames))
353 count, step = 0, 100
354 for i in xrange(0, len(hashes), step):
355 data = self.get_content_data(hashes=hashes.values()[i:i+step])
356 count += len(data)
357 if self['simulate']: continue
358 self.update_db(data)
359 self.db.commit()
360 print '%d %s added to database' % (count, cases(count, 'entry', 'entries'))
361
362 def split_path(self, pathname):
363 '''Split the path in a tuple of three'''
364 folder, name = os.path.split(pathname)
365 name, ext = os.path.splitext(name)
366 return folder, name, ext
367
368 def fix_filenames(self, pathname):
369 '''Rename files to id_hash'''
370 print 'Fixing filenames in %s…' % (pathname,)
371 filenames = self.get_filenames(pathname)
372 start, count = time(), 0
373 hashes = self.get_hashes(filenames, pathname, filter=False)
374 values = '”,”'.join(hashes.values())
375 query = self.cur.execute(self.by_md5_command % values)
376 query = dict(query.fetchall())
377 for filename, hash in hashes.iteritems():
378 if hash not in query.keys():
379 continue
380 folder, oldname, ext = self.split_path(filename)
381 # Figure out the new name (id is padded with zeroes)
382 newname = '%07d_%s%s' % (query[hash], hash, ext)
383 newname = os.path.join(folder, newname)
384 if filename == newname:
385 continue
386 if os.path.exists(newname):
387 os.remove(filename)
388 else:
389 try: os.rename(filename, newname)
390 except WindowsError, e:
391 print e
392 count -= 1
393 count += 1
394 print '%d %s fixed' % (count, case(count, 'filename'))
395
396 def expand_paths(self, source):
397 '''Does exactly what the name says'''
398 names = set()
399 for item in source.split():
400 names.update(glob(item))
401 return filter(os.path.isdir, names)
402
403 def get_filenames(self, pathname):
404 '''Again, does just what the name says'''
405 names = glob(os.path.join(pathname, '*'))
406 return filter(os.path.isfile, names)
407
408 def update_db(self, data):
409 '''Write data to the transaction (has to be committed to the db explicitly)'''
410 for key, value in data.iteritems():
411 value['author'] = value['author'].encode('utf-8')
412 values = (key, value.pop('md5'), value.pop('tags'), pickle.dumps(value))
413 try:
414 self.cur.execute(self.update_db_command % values)
415 except sqlite3.OperationalError, e:
416 print e
417
418
419 class Downloader(object):
420 '''Shows a progress bar for downloads. this is actually useful outside the
421 scope of danbooru.py'''
422
423 before = .0
424 history = []
425 cycles = 0
426 average = lambda self: sum(self.history) / (len(self.history) or 1)
427
428 def __init__(self, width=55):
429 self.width = width
430 self.kibi = lambda bits: bits / 2 ** 10
431 self.proc = lambda a, b: a / (b * 0.01)
432
433 def retrieve(self, url, destination, callback=None):
434 self.size = 0
435 xtime()
436 try: urllib.urlretrieve(url, destination, self.progress)
437 except KeyboardInterrupt:
438 print '\nDownload cancelled'
439 for i in range(5):
440 try:
441 os.remove(destination)
442 break
443 except:
444 sleep(.1)
445 else: raise
446 if callback: callback()
447 exit()
448 print
449 return self.size
450
451 def progress(self, blocks, blocksize, filesize):
452 self.cycles += 1
453 bits = min(blocks*blocksize, filesize)
454 done = self.proc(bits, filesize) if bits != filesize else 100
455 bar = self.bar(done)
456 if not self.cycles % 3 and bits != filesize:
457 now = xtime()
458 elapsed = now-self.before
459 if elapsed:
460 speed = self.kibi(blocksize * 3 / elapsed)
461 self.history.append(speed)
462 self.history = self.history[-4:]
463 self.before = now
464 average = round(sum(self.history[-4:]) / 4, 1)
465 self.size = self.kibi(bits)
466 print '\r[%s] %s KiB/s ' % (bar, str(average)),
467
468 def bar(self, done):
469 span = self.width * done * 0.01
470 offset = len(str(int(done))) - .99
471 result = ('%d%%' % (done,)).center(self.width)
472 return result.replace(' ', '-', int(span - offset))
473
474
475 def parse_options():
476 '''Parse arguments passed to the script'''
477 help = { 'limit': 'set how many posts (not files) to get from the api \
478 [default: %default]',
479 'offset': 'set the position to start downloading from \
480 [default: %default]',
481 'server': 'which server to use (takes an index, see -L for a list of \
482 available servers)',
483 'refresh': 'allow retrieving posts older than the highest id \
484 of the local files in the destination folder',
485 'nodb': 'allow downloading posts that are already present \
486 in the local database',
487 'catalogue': 'add local files to the database \
488 (queries the api with their hashes)',
489 'fixnames': 'change filenames to _.* format',
490 'folder': 'override the download destination (default is same as tags)',
491 'update': 'update the serverlist',
492 'list': 'see a list of available servers',
493 'set_default': 'set a default server',
494 'rating': 'convenience shortcut to the rating: tag',
495 'simulate': 'don\'t download files or add posts to the database',
496 }
497 usage = '%prog [-l NUM] [-o NUM] [-s NUM] [-r safe|questionable|explicit] \
498 [-f PATH] [-i] [-n] [-c PATH] [-x PATH] [-u] [-L] [-d] '
499 from optparse import OptionParser
500 parser = OptionParser(usage=usage, version='%s.%s' % (__version__, __build__),
501 description='A tool for retrieving content from danbooru.donmai.us')
502 parser.add_option('-l', '–limit', dest='limit', help=help['limit'], \
503 metavar='NUM', default=1000, type='int')
504 parser.add_option('-o', '–offset', dest='offset', help=help['offset'], \
505 metavar='NUM', default=0, type='int')
506 parser.add_option('-s', '–server', dest='server', help=help['server'], \
507 metavar='NUM', default=None, type='int')
508 parser.add_option('-r', '–rating', dest='rating', help=help['rating'], \
509 metavar='NAME', default=None, type='string')
510 parser.add_option('-f', '–folder', dest='folder', help=help['folder'], \
511 metavar='PATH', default=None)
512 parser.add_option('-i', '–no-last-id', dest='refresh', \
513 help=help['refresh'], action='store_true', default=False)
514 parser.add_option('-n', '–no-db', dest='nodb', help=help['nodb'], \
515 action='store_true', default=False)
516 parser.add_option('-c', '–catalogue', dest='catalogue', \
517 help=help['catalogue'], metavar='PATH', default=None)
518 parser.add_option('-x', '–fix', dest='fixnames', help=help['fixnames'], \
519 metavar='PATH', default=None)
520 parser.add_option('-u', '–update', dest='update', help=help['update'], \
521 action='store_true', default=False)
522 parser.add_option('-L', '–list', dest='list', help=help['list'], \
523 action='store_true', default=False)
524 parser.add_option('-d', '–default', dest='set_default', \
525 help=help['set_default'], metavar='ID', default=None, type='int')
526 parser.add_option('-e', '–simulate', dest='simulate', \
527 help=help['simulate'], action='store_true', default=False)
528 options, args = parser.parse_args()
529 return options, args, parser
530
531
532 def main():
533 '''Decide what to do based on the options returned by optparse'''
534 options, args, parser = parse_options()
535 robot = Robot(args, options.limit, options.offset, rating=options.rating,
536 refresh=options.refresh, nodb=options.nodb, simulate=options.simulate)
537 if options.rating:
538 values = ('safe', 'explicit', 'questionable')
539 if options.rating not in values:
540 parser.error('only %r are valid ratings' % (values,))
541 else:
542 robot.rating = options.rating
543 if options.refresh:
544 print 'Using No Last ID mode…'
545 if options.folder:
546 robot.folder = options.folder
547 if options.catalogue:
548 for name in robot.expand_paths(options.catalogue):
549 robot.catalogue_content(name)
550 if options.fixnames:
551 for name in robot.expand_paths(options.fixnames):
552 robot.fix_filenames(name)
553 if options.update:
554 robot.update_servers()
555 if options.set_default:
556 server_id = options.set_default
557 robot.use_server(server_id)
558 robot.save_settings(default=server_id)
559 print 'Default server set to %d (%s)' % \
560 (server_id, robot.servers[server_id]['host'])
561 if 'default' not in robot.settings:
562 server_id = robot.servers.keys()[0]
563 robot.use_server(server_id)
564 robot.save_settings(default=server_id)
565 if options.list:
566 robot.list_servers()
567 elif options.server:
568 robot.use_server(options.server)
569 else:
570 robot.use_server(robot.settings['default'])
571 if robot.tags:
572 if 'server' not in dir(robot):
573 robot.use_server(robot.settings['default'])
574 print 'Using server %d (%s)' % \
575 (robot.server, robot.servers[robot.server]['host'])
576 robot.retrieve_content()
577 robot.exit()
578
579
580 if __name__ == '__main__':
581 main()

13 Comments »

№ 1 Zealot 2009.08.11 @ 23:39

Hi Reinis Ivanovs !

I’m not english so excuse my mistakes.

First : thanks for this script,
I’m using this on Danbooru but since a few hours he don’t work !
When i’m using the script he tell me “0 post return” but when i go to Danbooru the tag work.

Can you fix this error ? Or it’s a error from Danbooru maybe ?

Thanks in advance !

PS : the script for moe.imouto work.

№ 2 Reinis Ivanovs 2009.08.17 @ 14:21

Sorry, it’s nothing I can fix. As it says above:

[..] Danbooru has been limiting the API access as of late, so a lot of the times the script doesn’t work.

My suggestion is to simply wait and try again later.

№ 3 Pow 2009.08.20 @ 23:56

It was only limited for anonymous users and those with “member” status. “privileged” status and up doesn’t have this restriction.

№ 4 Zealot 2009.08.21 @ 2:49

The script is working again :D

Thanks for you work !

№ 5 Xor 2009.08.30 @ 0:31

Thanks for your script!

Afaik, Sankaku Complex is a danbooru site too? Do you think I could adapt your code for this one? Thanks again!

№ 6 Xor 2009.08.30 @ 1:13

done, works fine, thank you!

№ 7 Xor 2009.08.31 @ 21:45

I just cant’t make the database to work fine, I don’t understand how your get_data could work seeing the api.
This :md5_path = ‘find_posts?md5=%s’, for instance, I just cannot see it in the api documentation, and it doesn’t seem to work, thus get_data always return a 404…

№ 8 Reinis Ivanovs 2009.08.31 @ 23:34

I’m not immediately sure what your problem was, but the script is old, has accumulated some quick and dirty fixes, and danbooru’s API has changed
a bit. In any event, I replaced the URLs in moe.py and it seems to work with Sankaku Complex. Here you go.

№ 9 Xor 2009.09.01 @ 22:02

thanks but I finally managed to make the databasework correctly with the catalogue option. I tried to add EXIFs tags to the images with some library but it’s not working as of now.
well I modified a few things to make the catalogue work, such as changing md5_path = ‘find_posts?md5=%s’ which doesn’t seem to work anymore.
I also added a few functions to sort the images by rating, but I made it quite dirtily, as I’ve been using python since yesterday…
Your script is quite complex for a beginner, heh!

№ 10 Reinis Ivanovs 2009.09.04 @ 12:18

Here’s a version that someone sent me for Konachan.com.

№ 11 Malart 2009.10.27 @ 16:47

I just wanted to say that Danbooru seems to have implemented a mandatory free account to view images and the script doesn’t seem to work anymore… would there be a way around this? I’m not really a programmer… but would there be any way to be able to enter your account info in the script and have it log in to your account in order to download? Or am I being a noob? heh

№ 12 Reinis Ivanovs 2009.10.27 @ 16:50

Yeah, their API seems to provide a way to log in. I suppose I’ll update the script when I have more free time. Thanks for the heads up.

№ 13 Draker 2010.01.06 @ 4:49

RSS feed for comments on this post. TrackBack URI

Leave a Comment

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>