summaryrefslogtreecommitdiff
path: root/twitlist.pl
blob: 067e5c3c19428fea49e0960f2844cf3ea98bfc56 (plain)
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
#!perl 
use strict;
use warnings;
use 5.020;
use experimental 'postderef';
use Net::Twitter;
use Path::Tiny;
use JSON;
use Try::Tiny;
use Safe::Isa;
use open ':std',':locale';
use Data::Printer;
 
=head1 NAME
 
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<https://apps.twitter.com/>.
 
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<NOTE>: this script will not actually write to Twitter unles you set the C<TWITTER_WRITE> 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 = path(__FILE__)->basename('.pl') . '.json';
    my $conf = decode_json(path($config_file)->slurp_raw);
    return Net::Twitter->new(traits=>[
        'API::RESTv1_1',
        'AutoCursor',
        'AutoCursor' => {
            max_calls => 15,
            force_cursor => 1,
            array_accessor => 'users',
            methods => [qw(friends followers)],
        },
    ],%$conf);
}
 
# returns a hashref shaped like: 
# { 
#   $list_id => { 
#     name => $list_name, 
#     members => { $user_screen_name => 1, ... }, 
#   }, ... 
# } 
sub fetch_lists_info {
    my ($tw) = @_;
 
    my $lists = $tw->list_ownerships({
        count => 200,
    });
    my %lists_info = map {
        $_->{id} => {
            $_->%{qw(name)},
        },
    $lists->{lists}->@*;
 
    for my $list_id (sort keys %lists_info) {
        my $members = $tw->list_members({
            list_id => $list_id,
            count => 2000,
            skip_status => 1,
            include_entities => 0,
        });
        $lists_info{$list_id}->{members}={
            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) = @_;
 
    my $friends = $tw->friends({
        count => 200,
        skip_status => 1,
        include_user_entities => 0,
    });
 
    my %friends_info = map {
        $_->{id} => { $_->%{qw(name screen_name)} }
    $friends->@*;
 
    return \%friends_info;
}
 
sub cache_file { path(__FILE__)->basename('.pl') .'-cache.json' };
 
sub load_info {
    return try { decode_json(path(cache_file)->slurp_raw) };
}
 
sub save_info {
    path(cache_file)->spew_raw(encode_json({
        lists=>$_[0],
        friends=>$_[1],
    }));
    return;
}
 
# from { $id => { name => $n, ...}, ... } 
# to ( { id => $id, name => $n, ...}, ... ) 
# sorted by name 
sub to_list {
    sort {
        $a->{namecmp $b->{name}
    map {
        id => $_$_[0]->{$_}->%* }
    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} = $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) = @_;
 
    my @lists = to_list($li);
    set_display_name(\@lists);
    my @friends = to_list($fi);
    my $friend_width = set_display_name(\@friends);
 
    for my $f (@friends) {
        printf '%-*s : ',$friend_width,$f->{dn};
        print join '',
            map { $_->{dn} }
            grep { $_->{members}->{$f->{screen_name}} }
            @lists;
        print "\n";
    }
}
 
# given a filename, parses users & lists out of it 
sub parse_friends_lists_matrix {
    my ($fn) = @_;
 
    my @lines = path($fn)->lines_utf8;
 
    my %lists;
    for my $l (@lines) {
        # 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) {
            # 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 => $_,
            $current_lists_by_id->{$_}->%*,
        };
    keys $current_lists_by_id->%*;
 
    # first, lists to create 
    for my $list (keys $wanted_lists->%*) {
        unless ($current_lists_by_name{$list}) {
            warn "creating $list\n";
            my $list_data;
            if ($WRITING) {
                $list_data = $tw->create_list({name=>$list,mode=>'private'});
            }
            else {
                $list_data = { id => int(rand(10000)) };
            }
            $current_lists_by_name{$list} = {
                $list_data->%{id},
                members => {},
            };
        }
    }
 
    # then, set members 
    for my $list (keys $wanted_lists->%*) {
        warn "operating on $list\n";
        my %current_members = $current_lists_by_name{$list}->{members}->%*;
        my %wanted_members = $wanted_lists->{$list}{members}->%*;
        my %to_add = %wanted_membersdelete @to_add{keys %current_members};
        my %to_remove = %current_membersdelete @to_remove{keys %wanted_members};
        my @to_remove=keys %to_remove;
        while (my @these = splice @to_remove,0,100) {
            warn "Removing @these from $list\n";
            if ($WRITING) {
                $tw->remove_list_members({
                    list_id => $current_lists_by_name{$list}{id},
                    screen_name => \@these,
                });
            }
        }
        my @to_add=keys %to_add;
        while (my @these = splice @to_add,0,100) {
            warn "Adding @these to $list\n";
            if ($WRITING) {
                $tw->add_list_members({
                    list_id => $current_lists_by_name{$list}{id},
                    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 {
    # error handling: is it a Twitter exception? 
    if ($_->$_isa('Net::Twitter::Error')) {
        # print it out 
        $_;
        # 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;
            $limit;
        }
    }
    # no, just re-throw 
    else { local $@=$_;die }
};