I’ve now been using Twitter for about six months. While Twitter’s minimalism is no doubt responsible for much of its success, I often pine for some additional social networking features. High up on that list is a simple way of representing my closest neighbors—perhaps through a visualization—without having to manually navigate individual users’ following/followers pages. A well designed representation could be useful in a number of ways:
- It could expose previously unknown mutual relationships (i.e., “Wow, I didn’t know X and Y knew each other!);
- It could reveal mutual acquaintances whom one did not know were on Twitter; and
- Metrics on the social network could be aggregated (e.g., degrees of separation).
The code for TwitterGraph follows at the end of this post. The code depends on the simplejson module and also imagemagick. It uses the Twitter API to construct the network graph. You don’t need to have a Twitter account for this to work; it doesn’t require authentication. Each IP is, however, limited to 100 API calls per hour, unless your IP has been whitelisted. My script takes this into account. Each Twitter user requires three API to download their information, so one can load about 33 users per hour before reaching the rate limit. TwitterGraph saves its data, so successive calls will continue off where it previously left. Finally, TwitterGraph also calculates the PageRank algorithm).
Usage: paste the code below into TwitterGraph.py
and run the following:
$ chmod 755 ./TwitterGraph.py $ ./TwitterGraph.py You have 100 API calls remaining this hour; how many would you like to use now? 80 What is the twitter username for which you'd like to build a graph? ESultanik Building the graph for ESultanik (output will be ESultanik.dot)... . . . $ dot -Tps ESultanik.dot -o ESultanik.ps && epstopdf ESultanik.ps && acroread ESultanik.pdf $ dot -Tsvgz ESultanik.dot -o ESultanik.svgz
There are also (unnecessary) command line options, the usage for which should be evident from the sourcecode.
#!/usr/bin/python import simplejson import urllib2 import urllib import getopt, sys import re import os class TwitterError(Exception): @property def message(self): return self.args[0] def CheckForTwitterError(data): if 'error' in data: raise TwitterError(data['error']) def fetch_url(url): opener = urllib2.build_opener() url_data = opener.open(url).read() opener.close() return url_data def remaining_api_hits(): json = fetch_url("http://twitter.com/account/rate_limit_status.json") data = simplejson.loads(json) CheckForTwitterError(data) return data['remaining_hits'] def get_user_info(id): global is_username global calls json = None calls += 1 if is_username: json = fetch_url("http://twitter.com/users/show.json?screen_name=" + str(id)) else: json = fetch_url("http://twitter.com/users/show.json?user_id=" + str(id)) data = simplejson.loads(json) CheckForTwitterError(data) return data def get_friends(id): global calls calls += 1 json = fetch_url("http://twitter.com/friends/ids.json?user_id=" + str(id)) data = simplejson.loads(json) CheckForTwitterError(data) return data def get_followers(id): global calls calls += 1 json = fetch_url("http://twitter.com/followers/ids.json?user_id=" + str(id)) data = simplejson.loads(json) CheckForTwitterError(data) return data last_status_msg = "" def update_status(message): global last_status_msg # clear the last message sys.stdout.write("\r") p = re.compile(r"[^\s]") sys.stdout.write(p.sub(' ', last_status_msg)) sys.stdout.write("\r") sys.stdout.write(message) last_status_msg = message sys.stdout.flush() def clear_status(): last_status_msg = "" def save_state(): global history global user_info global friends global followers global queue global username data = simplejson.dumps([history, user_info, friends, followers, queue]) bakfile = open(username + ".json", "w") bakfile.write(data) bakfile.close() def build_adjacency(): global friends idxes = {} adj = [] idx = 0 for user in friends: idxes[user] = idx idx += 1 adj.append([0]*len(friends)) for user in friends: if len(friends[user]) <= 0: continue amount_to_give = 1.0 / len(friends[user]) for f in friends[user]: if str(f) in idxes: adj[idxes[user]][idxes[str(f)]] = amount_to_give return [idxes, adj] try: opts, args = getopt.getopt(sys.argv[1:], "hu:c:r", ["help", "user=", "calls=", "resume"]) except getopt.GetoptError, err: print err #usage() sys.exit(2) max_calls = -1 username = "" load_prev = None for o, a in opts: if o in ("-h", "--help"): #usage() sys.exit() elif o in ("-u", "--user"): username = a elif o in ("-c", "--calls"): max_calls = int(a) elif o in ("-r", "--resume"): load_prev = True else: assert False, "unhandled option" if max_calls != 0: # First, let's find out how many API calls we have left before we are rate limited: update_status("Contacting Twitter to see how many API calls are left on your account...") max_hits = remaining_api_hits() if max_calls < 0 or max_hits < max_calls: update_status("You have " + str(max_hits) + " API calls remaining this hour; how many would you like to use now? ") max_calls = int(raw_input()) clear_status() if max_calls > max_hits: max_calls = max_hits if username == "": print "What is the twitter username for which you'd like to build a graph? ", username = re.compile(r"\n").sub("", raw_input()) update_status("Trying to open " + username + ".dot for output...") dotfile = open(username + ".dot", "w") update_status("") clear_status() print "Building the graph for " + username + " (output will be " + username + ".dot)..." is_username = True history = {} queue = [username] calls = 0 user_info = {} friends = {} followers = {} # Let's see if there's any partial data... if os.path.isfile(username + ".json"): print "It appears as if you have some partial data for this user." resume = "" if not load_prev: print "Do you want to start off from where you last finished? (y/n) ", resume = re.compile(r"\n").sub("", raw_input()) if load_prev == True or resume == "y" or resume == "Y" or resume == "yes" or resume == "Yes" or resume == "YES": is_username = False bakfile = open(username + ".json", "r") [history, user_info, friends, followers, queue] = simplejson.loads(bakfile.read()) print str(len(friends)) + " friends!" bakfile.close() print "Loaded " + str(len(history)) + " previous Twitterers!" print "The current queue size is " + str(len(queue)) + "." else: print "You are about to overwrite the partial data; are you sure? (y/n) ", resume = re.compile(r"\n").sub("", raw_input()) if not (resume == "y" or resume == "Y" or resume == "yes" or resume == "Yes" or resume == "YES"): exit while len(queue) > 0 and calls + 3 <= max_calls: next_user = queue.pop(0) # Let's just double-check that we haven't already processed this user! if str(next_user) in history: continue update_status(str(next_user) + "\t(? Followers,\t? Following)\tQueue Size: " + str(len(queue))) if next_user in user_info: info = user_info[next_user] else: try: info = get_user_info(next_user) except urllib2.HTTPError: update_status("It appears as if user " + str(next_user) + "'s account has been suspended!") print "" clear_status() continue uid = next_user if is_username: uid = info['id'] history[uid] = True is_username = False user_info[uid] = info update_status(info['screen_name'] + "\t(? Followers,\t? Following)\tQueue Size: " + str(len(queue))) followers[uid] = get_followers(uid) for i in followers[uid]: if str(i) not in history: history[i] = True queue.append(i) update_status(info['screen_name'] + "\t(" + str(len(followers[uid])) + " Followers,\t? Following)\tQueue Size: " + str(len(queue))) friends[uid] = get_friends(uid) for i in friends[uid]: if str(i) not in history: history[i] = True queue.append(i) update_status(info['screen_name'] + "\t(" + str(len(followers[uid])) + " Followers,\t" + str(len(friends[uid])) + " Following)") clear_status() sys.stdout.write("\n") sys.stdout.flush() save_state() # Get some extra user info if we have any API calls remaining # Find someone in the history for whom we haven't downloaded user info for user in history: if calls >= max_calls: break if not user in user_info: try: user_info[user] = get_user_info(user) except urllib2.HTTPError: # This almost always means the user's account has been disabled! continue if calls > 0: save_state() # Now download any user profile pictures that we might be missing... update_status("Downloading missing user profile pictures...") if not os.path.isdir(username + ".images"): os.mkdir(username + ".images") user_image_raw = {} for u in friends: _, _, filetype = user_info[u]['profile_image_url'].rpartition(".") filename = username + ".images/" + str(u) + "." + filetype user_image_raw[u] = filename if not os.path.isfile(filename): # we need to download the file! update_status("Downloading missing user profile picture for " + user_info[u]['screen_name'] + "...") urllib.urlretrieve(user_info[u]['profile_image_url'], filename) update_status("Profile pictures are up to date!") print "" clear_status() # Now scale the profile pictures update_status("Scaling profile pictures...") user_image = {} for u in friends: _, _, filetype = user_info[u]['profile_image_url'].rpartition(".") filename = username + ".images/" + str(u) + ".scaled." + filetype user_image[u] = filename if not os.path.isfile(filename): # we need to scale the image! update_status("Scaling profile picture for " + user_info[u]['screen_name'] + "...") os.system("convert -resize 48x48 " + user_image_raw[u] + " " + user_image[u]) update_status("Profile pictures are all scaled!") print "" clear_status() update_status("Building the adjacency matrix...") [idxes, adj] = build_adjacency() print "" clear_status() update_status("Calculating the stationary distribution...") iterations = 500 damping_factor = 0.25 st = [1.0]*len(friends) last_percent = -1 for i in range(iterations): users = 0 for u in friends: users += 1 percent = round(float(i * len(friends) + users) / float(iterations * len(friends)) * 100.0, 1) if percent > last_percent: last_percent = percent update_status("Calculating the stationary distribution... " + str(percent) + "%") idx = idxes[str(u)] given_away = 0.0 give_away = st[idx] * (1.0 - damping_factor) if give_away <= 0.0: continue for f in friends[u]: if str(f) not in friends: continue fidx = idxes[str(f)] ga = adj[idx][fidx] * give_away given_away += ga st[fidx] += ga st[idx] -= given_away print "" clear_status() # Now calculate the ranks of the users deco = [ (st[idxes[u]], i, u) for i, u in enumerate(friends.keys()) ] deco.sort() deco.reverse() rank = {} last_st = None last_rank = 1 for st, _, u in deco: if last_st == None: rank[u] = 1 elif st == last_st: rank[u] = last_rank else: rank[u] = last_rank + 1 last_rank = rank[u] last_st = st print user_info[u]['screen_name'] + "\t" + str(rank[u]) update_status("Generating the .dot file...") # Now generate the .dot file dotfile.write("digraph twitter {\n") dotfile.write(" /* A TwitterGraph automatically generated by Evan Sultanik's Python script! */\n") dotfile.write(" /* http://www.sultanik.com/ */\n") for user in friends: dotfile.write(" n" + str(user) + " [label=<
" + user_info[user]['name'])
if not (user_info[user]['name'] == user_info[user]['screen_name']):
dotfile.write(" (" + user_info[user]['screen_name'] + ")") dotfile.write(" |
Rank: " + str(rank[user]) + " |