From b911752e02a44c71bad6f6384cb7c70056d749d2 Mon Sep 17 00:00:00 2001 From: dakkar Date: Sat, 19 Dec 2015 14:00:00 +0000 Subject: docs, and cleanup --- twitlist.pl | 155 ++++++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 120 insertions(+), 35 deletions(-) diff --git a/twitlist.pl b/twitlist.pl index 21c3ce5..067e5c3 100644 --- a/twitlist.pl +++ b/twitlist.pl @@ -9,13 +9,60 @@ use JSON; use Try::Tiny; use Safe::Isa; use open ':std',':locale'; +use Data::Printer; -my $WRITING=0; +=head1 NAME -use Data::Printer; +twitlist - create & update Twitter lists + +=head1 SYNOPSIS + + $ twitlist > lists.txt + $ edit lists.txt + $ twitlist lists.txt + +=head1 DESCRIPTION + +This program makes it a bit easier to create Twitter lists, and manage the people in them. + +Before you use this program, you need to create a JSON file in this same directory, containing your access credentials. +It should look like this: + + { + "consumer_key": "1234", + "consumer_secret": "5678", + "access_token": "9abc", + "access_token_secret": "def0" + } + +You can get the correct values by registering an application at +L. + +Once you've done that, run this script with no arguments: + + $ twitlist > lists.txt + +This will produce, on standard output, a text file with one Twitter account per line, and the lists they belong to. +It will look like this: + + @name The Full Name : list_one, list_two + +You can then edit that file, adding or removing list names after the colon. +If you add the name of a list that doesn't exist, it will be created. + +To apply your changes to your Twitter lists, re-run the script passing the text file as argument: + + $ twitlist lists.txt +I: this script will not actually write to Twitter unles you set the C environment variable to a true value. + +=cut + +my $WRITING=$ENV{TWITTER_WRITE} // 0; + +# create a Net::Twitter object with out tokens sub get_twitter { - my $config_file = $0 =~ s{\.[^.]+$}{.json}r; + my $config_file = path(__FILE__)->basename('.pl') . '.json'; my $conf = decode_json(path($config_file)->slurp_raw); return Net::Twitter->new(traits=>[ 'API::RESTv1_1', @@ -29,6 +76,13 @@ sub get_twitter { ],%$conf); } +# returns a hashref shaped like: +# { +# $list_id => { +# name => $list_name, +# members => { $user_screen_name => 1, ... }, +# }, ... +# } sub fetch_lists_info { my ($tw) = @_; @@ -37,7 +91,7 @@ sub fetch_lists_info { }); my %lists_info = map { $_->{id} => { - name => $_->{name}, + $_->%{qw(name)}, }, } $lists->{lists}->@*; @@ -49,12 +103,20 @@ sub fetch_lists_info { include_entities => 0, }); $lists_info{$list_id}->{members}={ - map { $_->{id} => 1 } $members->{users}->@*, + map { $_->{screen_name} => 1 } $members->{users}->@*, }; } return \%lists_info; } + +# returns a hashref shaped like: +# { +# $user_id => { +# name => $user_name, +# screen_name => $user_screen_name, +# }, ... +# } sub fetch_friends_info { my ($tw) = @_; @@ -71,7 +133,7 @@ sub fetch_friends_info { return \%friends_info; } -sub cache_file { $0 =~ s{\.[^.]+$}{-cache.json}r }; +sub cache_file { path(__FILE__)->basename('.pl') .'-cache.json' }; sub load_info { return try { decode_json(path(cache_file)->slurp_raw) }; @@ -85,6 +147,9 @@ sub save_info { return; } +# from { $id => { name => $n, ...}, ... } +# to ( { id => $id, name => $n, ...}, ... ) +# sorted by name sub to_list { sort { $a->{name} cmp $b->{name} @@ -93,16 +158,22 @@ sub to_list { } keys $_[0]->%* } +# sets the ->{dn} slot to the name, or the screen name + name +# returns the maximum length of the dns set sub set_display_name { my $max=0; for my $e ($_[0]->@*) { - $e->{dn} = sprintf '%s (%d)',$e->@{qw(name id)}; + $e->{dn} = $e->{screen_name} + ? sprintf '@%s (%s)',$e->@{qw(screen_name name)} + : sprintf '%s',$e->@{qw(name)}; my $l = length($e->{dn}); $max = $l if $max<$l; } return $max; } +# given the output of fetch_lists_info and fetch_friends_info +# prints the text file sub print_friends_lists_matrix { my ($li,$fi) = @_; @@ -115,12 +186,13 @@ sub print_friends_lists_matrix { printf '%-*s : ',$friend_width,$f->{dn}; print join ', ', map { $_->{dn} } - grep { $_->{members}->{$f->{id}} } + grep { $_->{members}->{$f->{screen_name}} } @lists; print "\n"; } } +# given a filename, parses users & lists out of it sub parse_friends_lists_matrix { my ($fn) = @_; @@ -128,30 +200,30 @@ sub parse_friends_lists_matrix { my %lists; for my $l (@lines) { - $l =~ s{\A.*?\((\d+)\)\s+:}{} or next; - my $friend_id = $1; + # the line should start with a @screen_screen, + # followed by a name in parentheses + $l =~ s{\A\@(\S+)\s+\(.*?\)\s*:}{} or next; + my $friend_name = $1; + # after the colon there should be a comma-separated list of list names + # (the map trims spaces out of each name) my @lists = map { s{\A\s*(.*?)\s*\z}{$1}r } split ',',$l; for my $list (@lists) { - my ($list_name,$list_id) = ($list =~ m{\A(.*?)(?:\s+\((\d+)\))?\z}); - next unless $list_name; - my $list_data = $lists{$list_name}||={}; - if ($list_id && $list_data->{id}) { - if ($list_data->{id} != $list_id) { - warn "List $list_name id conflict for friend $friend_id"; - } - } - else { - $list_data->{id} ||= $list_id; - } - $list_data->{members}->{$friend_id} = 1; + # we might get some 0-length strings + # if there's nothing after the colon + next unless $list; + # add the user to the members of the list + my $list_data = $lists{$list}||={}; + $list_data->{members}->{$friend_name} = 1; } } return \%lists; } +# this is the function that does most of the work sub make_it_so { my ($tw,$current_lists_by_id,$wanted_lists) = @_; + # we'll need to access the lists by name, let's build that hash my %current_lists_by_name = map { $current_lists_by_id->{$_}{name} => { id => $_, @@ -159,16 +231,9 @@ sub make_it_so { }; } keys $current_lists_by_id->%*; - my @operations; # first, lists to create for my $list (keys $wanted_lists->%*) { - if ($current_lists_by_name{$list}) { - $wanted_lists->{$list}{id} ||= $current_lists_by_name{$list}{id}; - if ($wanted_lists->{$list}{id} != $current_lists_by_name{$list}{id}) { - warn "list $list has conflicting ids!\n"; - } - } - else { + unless ($current_lists_by_name{$list}) { warn "creating $list\n"; my $list_data; if ($WRITING) { @@ -183,6 +248,7 @@ sub make_it_so { }; } } + # then, set members for my $list (keys $wanted_lists->%*) { warn "operating on $list\n"; @@ -196,7 +262,7 @@ sub make_it_so { if ($WRITING) { $tw->remove_list_members({ list_id => $current_lists_by_name{$list}{id}, - user_id => \@these, + screen_name => \@these, }); } } @@ -206,38 +272,57 @@ sub make_it_so { if ($WRITING) { $tw->add_list_members({ list_id => $current_lists_by_name{$list}{id}, - user_id => \@these, + screen_name => \@these, }); } } } } +# main program! my $tw = get_twitter; + try { my $lists_info; my $friends_info; + + # try loading from the cache my $info = load_info(); if ($info) { ($lists_info,$friends_info) = $info->@{qw(lists friends)}, } else { + # otherwise load from Twitter and save the cache $lists_info = fetch_lists_info($tw); $friends_info = fetch_friends_info($tw); save_info($lists_info,$friends_info); } + + # something to parse? if (@ARGV) { + # parse it my $wanted_lists = parse_friends_lists_matrix($ARGV[0]); + # and update Twitter make_it_so($tw,$lists_info,$wanted_lists); } else { + # no, just fetch and print print_friends_lists_matrix($lists_info,$friends_info); } -} catch { +} +catch { + # error handling: is it a Twitter exception? if ($_->$_isa('Net::Twitter::Error')) { - my $limit = $tw->rate_limit_status; - p $limit; + # print it out p $_; + # have we reached the rate limit? + if ($_->code == 429 or ( + $_->has_twitter_error and $_->twitter_error_code == 88)) { + # retrieve the limits and print them + my $limit = $tw->rate_limit_status; + p $limit; + } } + # no, just re-throw else { local $@=$_;die } }; -- cgit v1.2.3