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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 | #!/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(): 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: else : data = simplejson.loads(json) CheckForTwitterError(data) return data def get_friends( id ): global calls calls + = 1 data = simplejson.loads(json) CheckForTwitterError(data) return data def get_followers( id ): global calls calls + = 1 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" ) for user in friends: dotfile.write( " n" + str (user) + " [label=< " ) dotfile.write("") dotfile.write( "<table border=" \" 0 \" "><tbody><tr><td>" + user_info[user][ 'name' ]) if not (user_info[user][ 'name' ] = = user_info[user][ 'screen_name' ]): dotfile.write( "<br>(" + user_info[user][ 'screen_name' ] + ")" ) dotfile.write( "</td></tr><tr><td>Rank: " + str (rank[user]) + "</td></tr><tr><td><img src=" \"" " +=" " user_image[user]=" " " \ "=" " scale=" \"true\" / "></td></tr></tbody></table> >" ); if user_info[user][ 'screen_name' ] = = username: dotfile.write( " color=\"green\" shape=\"doubleoctagon\"" ) dotfile.write( "];\n" ) dotfile.write( "\n" ) for user in friends: for f in friends[user]: if str (f) in friends: dotfile.write( " n" + str (user) + " -> " + " n" + str (f) + ";\n" ) dotfile.write( "}\n" ) dotfile.close() print "" clear_status() |