#!/usr/bin/python # # A command-line LiveJournal friendslist manipulation program. # # Available operations: # add - add new friends # grm - remove friends from groups # groups - list the names of your friend groups # ln - add friends to groups # ls - list friends, with what friend groups they are in. # lsrev - list people who friend you that you do not friend. # mv - move friends out of friends groups and into others. # neaten - try to get everyone into just one friend group. # remove - remove friends. # # Your LiveJournal userid and password is expected to be in $HOME/.ljfrc # in a line of the form 'login '. Blank lines and comment # lines (starting with a '#' as the first non-whitespace) are allowed. # # grm and mv can take an augmented friend-naming syntax of the form # 'group/friend'; this means 'apply the operation to just this group # that the friend is in'. Otherwise, the operation is applied to all # groups that the friend is in. So 'mv silly/fred normal' means to # remove fred from silly and add fred to normal; 'mv fred normal' # means add fred to normal, removing him from all other # groups. Similarly 'grm silly/fred' means 'remove fred from silly', # whereas 'grm fred' means 'remove fred from all friends groups'. # # (Technically ln also takes this syntax, but it does nothing # useful in ln's case; 'ln silly/fred normal' and 'ln fred normal' # do the exact same thing.) # # neaten does the following two things: # 1: if someone is in no friends groups, they are added to Default View # 2: if someone is in more than one group, and Default View is one of them, # they are removed from Default View. # Neaten does nothing if you have no Default View group. # Neaten always reports what it is doing; you cannot shut it up. -n makes # it 'dryrun', not actually commit the changes. # Other operations are generally silent. # # ls options: # -[012] print (only) people in no friends groups, one friend group, # or more than one friend group respectively. normally # everyone is printed, which is equivalent to -012 # -q never print what groups people are in # -v always print what groups people are in # by default, ls prints what groups people are in unless # you are listing just one group. # -x do not list people who are only in Default View. # # lsrev options: # -a list all people who friend you # -m list only mutual friends. # (so it is sort of a general friendof lister now) import sys, os, os.path import xmlrpclib, socket, md5, time import sets, getopt class LJChatter: """Perform authenticated XMLRC with LiveJournal.""" def __init__(self, user, pw): self._u = user self._hp = md5.new(pw).hexdigest() # Obtain the starting XMLRPC handle. self.srv = xmlrpclib.ServerProxy("http://www.livejournal.com/interface/xmlrpc").LJ.XMLRPC def withauth(self, ljcall, data): """Perform a LiveJournal call with challenge/response authentication. The ljcall argument must be a ready to go XMLRPC handle to the desired XMLRPC operation.""" res = self.srv.getchallenge() ch = res["challenge"] data["auth_method"] = "challenge" data["auth_challenge"] = ch data["auth_response"] = md5.new("%s%s" % (ch, self._hp)).hexdigest() data["username"] = self._u data["ver"] = 1 return ljcall(data) def getfriends(self, lsrev = False): """Obtain the LiveJournal friendslist, optionally including friendsof data.""" data = {"includegroups": 1} if lsrev: data["includefriendof"] = 1 return self.withauth(self.srv.getfriends, data) def mvfriendgroups(self, trans): """Change the friends groups that friends are in, possibly adding friends in the process. (This uses LJ.XMPLRC.editfriends, not .editfriendsgroups.)""" return self.withauth(self.srv.editfriends, trans.to_data()) def delfriends(self, trans): """Unfriend people.""" return self.withauth(self.srv.editfriends, trans.to_del_data()) # This code assumes that people will not make contradictory changes. NGRPS = 1 class LJTrans: """Organize and track a series of changes to LiveJournal friends.""" def __init__(self, groups): self.groups = groups self.modusers = {} self.delusers = {} def _setup(self, user): un = user['username'] if un not in self.modusers: self.modusers[un] = user.get("groupmask", NGRPS) def clear_groups(self, user): """Remove a friend from all friends groups.""" self.modusers[user['username']] = NGRPS def add_group(self, user, groupname): """Add a friend to a friend group.""" self._setup(user) gm = self.groups.get_mask(groupname) self.modusers[user['username']] |= gm def del_group(self, user, groupname): """Remove a friend from a friend group.""" self._setup(user) gm = self.groups.get_mask(groupname) self.modusers[user['username']] &= ~gm def add_user(self, username, group = 'Default View'): """Add a new friend.""" gm = self.groups.get_mask(group) if not gm: gm = NGRPS self.modusers[username] = gm def del_user(self, username): """Remove a friend.""" self.delusers[username] = True def to_data(self): """Compile friend additions and friend group changes into the LJ XMLRPC format.""" assert not self.delusers, "self.delusers set in LJTrans.to_data: "+str(self.delusers) changed = [] for k, v in self.modusers.iteritems(): changed.append({'username': k, 'groupmask': v}) return {'add': changed} def to_del_data(self): """Compile friend deletions into the LJ XMLRPC format.""" assert not self.modusers, "self.modusers set in LJTrans.to_del_data: "+str(self.modusers) return {'delete': self.delusers.keys()} def __len__(self): return len(self.modusers) + len(self.delusers) class LJFError(Exception): pass class LJFUsage(Exception): pass class LJFArgs(Exception): pass class Groups: """Store and index and process the list of LiveJournal friendsgroups.""" def __init__(self, fgraw): self.nameidx = {} self.maskidx = [] self.gCache = {NGRPS: ()} for fg in fgraw: name = fg['name'] idn = fg['id'] self.nameidx[name] = fg self.maskidx.append((1 << idn, name)) # We might as well preload the cache. self.gCache[(1 << idn) | 1] = (name,) def get_groups(self, friend): """Get a list of the group names that a friend entry is in.""" gmask = friend.get("groupmask", NGRPS) if gmask not in self.gCache: gl = [x[1] for x in self.maskidx if x[0] & gmask] gl.sort() gl = tuple(gl) self.gCache[gmask] = gl return self.gCache[gmask] def get_mask(self, groupname): """Get the groupmask for a particular friend group.""" if groupname not in self.nameidx: return 0 return (1 << self.nameidx[groupname]['id']) def __getattr__(self, name): return getattr(self.nameidx, name) def keys(self): return self.nameidx.keys() class Friends: """Store and index the list of LiveJournal friends.""" def __init__(self, flist): self.fidx = {} for f in flist: self.fidx[f['username']] = f def __getattr__(self, name): return getattr(self.fidx, name) def keys(self): return self.fidx.keys() def get_friendslist(cxn, lsrev = False): """Retrieve the LiveJournal friendslist and parse it into structures.""" # Every now and then LiveJournal freaks out and drops the # friendslist (although not the groups list). Go figure. # So we retry it a couple of times. cnt = 3 while cnt: r = cxn.getfriends(lsrev) if 'friends' in r and len(r['friends']) > 0: break cnt = cnt - 1 sys.stderr.write("%s: LiveJournal XMLRPC problem, retrying: no friendslist returned.\n" % sys.argv[0]) time.sleep(2) continue # Hopefully we have gotten something back. friends = Friends(r['friends']) groups = Groups(r['friendgroups']) return (friends, groups, r) def setup_trans(cxn, musthave = None): """Set up for performing a group/friendslist change transaction. Get the list of friends and groups from LiveJournal, check for an optional group that must be in the grouplist, and return all this plus a transaction object.""" friends, groups, r = get_friendslist(cxn) if musthave and musthave not in groups: raise LJFArgs, "No such friends group: "+musthave return friends, groups, r, LJTrans(groups) def pretty_group_name(name): """Pretty up the name of a friendsgroup by putting quotes around names with spaces in them.""" if ' ' in name: return "'%s'" % name else: return name def pretty_grouplist(gl): """Format a grouplist tuple for printing""" return ', '.join([pretty_group_name(x) for x in gl]) def cmd_ls(cxn, opts, args): "Implement the ls command" lst_all = True lst_none = False lst_one = False lst_mult = False no_defview = False quiet = False verbose = False for o, a in opts: if o == '-0': lst_all = False lst_none = True elif o == '-1': lst_all = False lst_one = True elif o == '-2': lst_all = False lst_mult = True elif o == '-q': quiet = True elif o == '-v': verbose = True elif o == '-x': no_defview = True else: raise AssertionError, "unhandled ls option: "+o if quiet and verbose: raise LJFArgs, "cannot use both -q and -v" friends, groups, r = get_friendslist(cxn) fk = friends.keys() fk.sort() if not fk: print "No friends returned in the result!" # this is debugging output, and I should do better. print r return select_set = sets.ImmutableSet(args) for fn in fk: f = friends[fn] gl = groups.get_groups(f) # Filter out conditional groups. if not lst_all: if lst_none and len(gl) == 0: pass elif lst_one and len(gl) == 1: pass elif lst_mult and len(gl) > 1: pass else: continue # Exclude people in only the Default View, if asked. if no_defview and len(gl) == 1 and gl[0] == 'Default View': continue # Are we restricted to printing members of just the # groups that we got as arguments? if select_set: gset = sets.ImmutableSet(gl) if not gset.intersection(select_set): continue # Do we print the groups? if quiet or (len(args) == 1 and not verbose): grps = '' else: grps = pretty_grouplist(gl) print "%-20s %s" % (fn, grps) def cmd_lsrev(cxn, opts, args): "Implement the lsrev command" __pychecker__ = "no-argsused" show_all = False show_mutual = False for o, a in opts: if o == '-a': show_all = True elif o == '-m': show_mutual = True else: raise AssertionError, "unhandled lsrev option: "+o if show_all and show_mutual: raise LJFArgs, "-a and -m cannot be combined" friends, groups, r = get_friendslist(cxn, True) if 'friendofs' not in r: print "%s lsrev: could not get the friendof list" % sys.argv[0] return for f in r['friendofs']: mutual = f['username'] in friends if show_all: pass # This is the rare case where a logical XOR would be useful. elif (show_mutual and not mutual) \ or (not show_mutual and mutual): continue print "%-20s" % f['username'] def cmd_lsgrps(cxn, opts, args): "Implement the groups command" __pychecker__ = "no-argsused" _, groups, _ = get_friendslist(cxn) k = groups.keys() k.sort() for g in k: print g def cmd_neaten(cxn, opts, args): "Implement the neaten command" __pychecker__ = "no-argsused" dryrun = False for o, a in opts: if o == "-n": dryrun = True else: raise AssertionError, "unhandled neaten option: "+o # We can't do this if there's no 'Default View' in groups. friends, groups, _, trans = setup_trans(cxn, 'Default View') fk = friends.keys() fk.sort() for fn in fk: f = friends[fn] gl = groups.get_groups(f) if len(gl) == 0: print "%-20s add Default View: in no groups" % fn trans.add_group(f, 'Default View') elif len(gl) > 1 and 'Default View' in gl: print "%-20s del Default View: in %s" % (fn, pretty_grouplist(gl)) trans.del_group(f, 'Default View') if trans and not dryrun: print "%s neaten: comitting changes" % sys.argv[0] cxn.mvfriendgroups(trans) def argsplit(arg): """Split a possible Group/friend argument into group and friend. None is returned for anything missing.""" if '/' not in arg or arg[-1] == '/': return (None, arg) r = arg.rfind("/") return (arg[:r], arg[r+1:]) def walkfriends(friends, args): """Walk a list of friendsblobs, splitting into group and username, check to see if they are in friends, and yield the split up stuff.""" for fstr in args: (grp, fn) = argsplit(fstr) # I chose not to implicitly add friends in this operation, # because this way I guard against my own stupid typos. if fn not in friends: raise LJFArgs, "No such friend: "+fn f = friends[fn] yield grp, fn, f # Much of the code is common over ln and mv; the only difference # is whether people are moved out of their old group(s) when we # put them in a new one. def mvln_common(cxn, args, del_old): """The common core of mv and ln""" tgrp = args[-1] if tgrp[-1] == '/': tgrp = tgrp[:-1] frnds = args[:-1] friends, groups, _, trans = setup_trans(cxn, tgrp) for grp, fn, f in walkfriends(friends, frnds): if del_old: if not grp: trans.clear_groups(f) else: trans.del_group(f, grp) trans.add_group(f, tgrp) cxn.mvfriendgroups(trans) def cmd_mv(cxn, opts, args): "Implement the mv command" __pychecker__ = "no-argsused" mvln_common(cxn, args, True) def cmd_ln(cxn, opts, args): "Implement the ln command" __pychecker__ = "no-argsused" mvln_common(cxn, args, False) def cmd_grm(cxn, opts, args): "Implement the grm command" __pychecker__ = "no-argsused" friends, groups, _, trans = setup_trans(cxn) for grp, fn, f in walkfriends(friends, args): if not grp: trans.clear_groups(f) else: trans.del_group(f, grp) cxn.mvfriendgroups(trans) def cmd_friend(cxn, opts, args): "Implement the add command" tgrp = None for o, a in opts: if o == '-g': tgrp = a else: raise AssertionError, "unhandled friend option: "+o friends, groups, _, trans = setup_trans(cxn, tgrp) if not tgrp: tgrp = 'Default View' for fstr in args: if fstr in friends: print "%-20s already a friend" % fstr else: trans.add_user(fstr, tgrp) cxn.mvfriendgroups(trans) def cmd_unfriend(cxn, opts, args): "Implement the remove command" __pychecker__ = "no-argsused" friends, groups, _, trans = setup_trans(cxn) for fstr in args: if fstr not in friends: raise LJFArgs, "no friend '%s'" % fstr trans.del_user(fstr) cxn.delfriends(trans) def die(msg): "Exit with an error message." sys.stderr.write("%s: %s\n" % (sys.argv[0], msg)) sys.exit(1) def get_user_pass(): """Read the login and password from $HOME/.ljfrc.""" if 'HOME' not in os.environ: raise LJFError, "no $HOME environment variable" rcfile = os.path.join(os.environ["HOME"], ".ljfrc") try: fp = open(rcfile, "r") while 1: l = fp.readline() if not l: break l = l.strip() if not l or l[0] == "#": continue t = l.strip().split(None, 2) if len(t) != 3 or t[0] != "login": continue else: return t[1], t[2] raise LJFError, "could not find login information in "+rcfile except EnvironmentError, e: raise LJFError, "problem reading %s: %s" % (rcfile, str(e)) def startup(): """Start up a LiveJournal connection object. Fishes user and password information from $HOME/.ljfrc.""" try: r = get_user_pass() except LJFError, e: die("could not get user and password: "+str(e)) return LJChatter(r[0], r[1]) def usage(): "Report ljf's usage message." sys.stderr.write("usage: %s [cmd args]\n" % sys.argv[0]) sys.stderr.write("available commands and arguments:\n") kys = cmds.keys() kys.sort() for k in kys: sys.stderr.write(" %s %s\n" % (k, cmds[k][1])) sys.exit(1) cmds = {'ls': (cmd_ls, '[-012] [-q|v] [-x] [group [group ...]]', "012qvx", 0, -1), 'neaten': (cmd_neaten, "[-n]", "n", 0, 0), "mv": (cmd_mv, "friend [friend ...] group", "", 2, -1), "ln": (cmd_ln, "friend [friend ...] group", "", 2, -1), "grm": (cmd_grm, "friend [friend ...]", "", 1, -1), "add": (cmd_friend, "[-g group] friend [friend ...]", "g:", 1, -1), "remove": (cmd_unfriend, "friend [friend ...]", "", 1, -1), "lsrev": (cmd_lsrev, "[-a|m]", "am", 0, 0), "groups": (cmd_lsgrps, "", "", 0, 0), } def main(args): "Perform main ljf processing." if len(args) < 1: usage() cxn = startup() # Pick function cmd = args[0] if cmd in cmds: (cmd_func, ustr, gostr, mina, maxa) = cmds[cmd] else: usage() args = args[1:] # Run the function. try: opts, args = getopt.getopt(args, gostr) if len(args) < mina: raise LJFUsage, "too few arguments" if maxa == 0 and args: raise LJFUsage, "takes no arguments" cmd_func(cxn, opts, args) except xmlrpclib.Fault, e: die("problem talking with LiveJournal: "+e.faultString) except xmlrpclib.ProtocolError, e: die("XMLPRC problem: "+str(e)) except socket.error, e: die("network problem while talking with LiveJournal: "+str(e)) except (getopt.GetoptError, LJFUsage), e: sys.stderr.write("%s %s: %s\n" % (sys.argv[0], cmd, e)) sys.stderr.write("usage %s %s %s\n" % (sys.argv[0], cmd, ustr)) sys.exit(1) except LJFArgs, e: sys.stderr.write("%s %s: %s\n" % (sys.argv[0], cmd, e)) sys.exit(1) if __name__ == "__main__": main(sys.argv[1:])