File Coverage

blib/lib/WebService/Google/Reader.pm
Criterion Covered Total %
statement 34 36 94.4
branch n/a
condition n/a
subroutine 12 12 100.0
pod n/a
total 46 48 95.8


line stmt bran cond sub pod time code
1             package WebService::Google::Reader;
2              
3 2     2   54627 use strict;
  2         5  
  2         77  
4 2     2   10 use warnings;
  2         3  
  2         63  
5 2     2   2346 use parent qw(Class::Accessor::Fast);
  2         670  
  2         12  
6              
7 2     2   9463 use Carp qw(croak);
  2         5  
  2         177  
8 2     2   3655 use HTTP::Request::Common qw(GET POST);
  2         97071  
  2         171  
9 2     2   2427 use LWP::UserAgent;
  2         56868  
  2         100  
10 2     2   2916 use JSON;
  2         32545  
  2         13  
11 2     2   388 use URI;
  2         6  
  2         55  
12 2     2   13 use URI::Escape;
  2         3  
  2         136  
13 2     2   1856 use URI::QueryParam;
  2         1498  
  2         62  
14              
15 2     2   1320 use WebService::Google::Reader::Constants;
  2         4  
  2         303  
16 2     2   2219 use WebService::Google::Reader::Feed;
  0            
  0            
17             use WebService::Google::Reader::ListElement;
18              
19             our $VERSION = '0.22';
20             $VERSION = eval $VERSION;
21              
22             __PACKAGE__->mk_accessors(qw(
23             auth compress error host password response scheme token ua username
24             ));
25              
26             sub new {
27             my ($class, %params) = @_;
28              
29             my $self = bless { %params }, $class;
30              
31             croak q('host' is required) unless $self->host;
32             my $host = $self->host;
33             $host = "http://$host" unless $host =~ m[^https?://];
34             $host =~ s[/+$][];
35             $self->host($host);
36              
37             my $ua = $params{ua};
38             unless (ref $ua and $ua->isa(q(LWP::UserAgent))) {
39             $ua = LWP::UserAgent->new(
40             agent => __PACKAGE__.'/'.$VERSION,
41             );
42             $self->ua($ua);
43             }
44              
45             $self->compress(1);
46             $self->debug(0);
47             for my $accessor (qw( compress debug )) {
48             $self->$accessor($params{$accessor}) if exists $params{$accessor};
49             }
50              
51             $ua->default_header(accept_encoding => 'gzip,deflate')
52             if $self->compress;
53              
54             $self->scheme($params{secure} || $params{https} ? 'https' : 'http');
55              
56             return $self;
57             }
58              
59             sub debug {
60             my ($self, $val) = @_;
61             return $self->{debug} unless 2 == @_;
62              
63             my $ua = $self->ua;
64             if ($val) {
65             my $dump_sub = sub { $_[0]->dump(maxlength => 0); return };
66             $ua->set_my_handler(request_send => $dump_sub);
67             $ua->set_my_handler(response_done => $dump_sub);
68             }
69             else {
70             $ua->set_my_handler(request_send => undef);
71             $ua->set_my_handler(response_done => undef);
72             }
73              
74             return $self->{debug} = $val;
75             }
76              
77             ## Feeds
78              
79             sub feed { shift->_feed(feed => shift, @_) }
80             sub tag { shift->_feed(tag => shift, @_) }
81             sub state { shift->_feed(state => shift, @_) }
82              
83             sub shared { shift->state(broadcast => @_) }
84             sub starred { shift->state(starred => @_) }
85             sub liked { shift->state(like => @_) }
86             sub unread {
87             shift->state(
88             'reading-list', exclude => { state => 'read' }, @_
89             );
90             }
91              
92             sub search {
93             my ($self, $query, %params) = @_;
94              
95             $self->_login or return;
96             $self->_token or return;
97              
98             my $uri = URI->new($self->host . SEARCH_ITEM_IDS_PATH);
99              
100             my %fields = (num => $params{results} || 1000);
101              
102             my @types = grep { exists $params{$_} } qw(feed state tag);
103             for my $type (@types) {
104             push @{$fields{s}}, _encode_type($type, $params{$type});
105             }
106              
107             $uri->query_form({ q => $query, %fields, output => 'json' });
108              
109             my $req = HTTP::Request->new(GET => $uri);
110             my $res = $self->_request($req) or return;
111              
112             my @ids = do {
113             my $ref = eval { from_json($res->decoded_content) } or do {
114             $self->error("Failed to parse JSON response: $@");
115             return;
116             };
117             map { $_->{id} } @{$ref->{results}};
118             };
119             return unless @ids;
120             if (my $order = $params{order} || $params{sort}) {
121             @ids = reverse @ids if 'asc' eq $order;
122             }
123              
124             my $feed = (__PACKAGE__.'::Feed')->new(
125             request => $req, ids => \@ids, count => $params{count} || 40,
126             );
127             return $self->more($feed);
128             }
129              
130             sub more {
131             my ($self, $feed) = @_;
132              
133             my $req;
134             if (defined $feed->ids) {
135             my @ids = splice @{$feed->ids}, 0, $feed->count;
136             return unless @ids;
137              
138             my $uri = URI->new($self->host . STREAM_ITEMS_CONTENTS_PATH);
139             $req = POST $uri, [ output => 'atom', map { (i => $_) } @ids ];
140             }
141             elsif ($feed->elem) {
142             return unless defined $feed->continuation;
143             return if $feed->entries < $feed->count;
144             $req = $feed->request;
145             my $prev_continuation = $req->uri->query_param('c') || '';
146             return if $feed->continuation eq $prev_continuation;
147             $req->uri->query_param(c => $feed->continuation);
148             }
149             elsif ($req = $feed->request) {
150             # Initial request.
151             }
152             else { return }
153              
154             my $res = $self->_request($req) or return;
155              
156             $feed->init(Stream => $res->decoded_content(ref => 1)) or return;
157             return $feed;
158             }
159              
160             *previous = *previous = *next = *next = \&more;
161              
162             ## Lists
163              
164             sub tags { $_[0]->_list($_[0]->host . LIST_TAGS_PATH) }
165             sub feeds { $_[0]->_list($_[0]->host . LIST_SUBS_PATH) }
166             sub preferences { $_[0]->_list($_[0]->host . LIST_PREFS_PATH) }
167             sub counts { $_[0]->_list($_[0]->host . LIST_COUNTS_PATH) }
168             sub userinfo { $_[0]->_list($_[0]->host . LIST_USER_INFO_PATH) }
169              
170             ## Edit tags
171              
172             sub edit_tag { shift->_edit_tag(tag => @_) }
173             sub edit_state { shift->_edit_tag(state => @_) }
174             sub share_tag { shift->edit_tag(_listify(\@_), share => 1) }
175             sub unshare_tag { shift->edit_tag(_listify(\@_), unshare => 1) }
176             sub share_state { shift->edit_state(_listify(\@_), share => 1) }
177             sub unshare_state { shift->edit_state(_listify(\@_), unshare => 1) }
178             sub delete_tag { shift->edit_tag(_listify(\@_), delete => 1) }
179             sub mark_read_tag { shift->mark_read(tag => _listify(\@_)) }
180             sub mark_read_state { shift->mark_read(state => _listify(\@_)) }
181              
182             sub rename_feed_tag {
183             my ($self, $old, $new) = @_;
184              
185             my @tagged;
186             my @feeds = $self->feeds or return;
187              
188             # Get the list of subs which are associated with the tag to be renamed.
189             FEED:
190             for my $feed (@feeds) {
191             for my $cat ($self->categories) {
192             for my $o ('ARRAY' eq ref $old ? @$old : ($old)) {
193             if ($o eq $cat->label or $o eq $cat->id) {
194             push @tagged, $feed->id;
195             next FEED;
196             }
197             }
198             }
199             }
200              
201             $_ = [ _encode_type(tag => $_) ] for ($old, $new);
202              
203             return $self->edit_feed(\@tagged, tag => $new, untag => $old);
204             }
205              
206             sub rename_entry_tag {
207             my ($self, $old, $new) = @_;
208              
209             for my $o ('ARRAY' eq ref $old ? @$old : ($old)) {
210             my $feed = $self->tag($o) or return;
211             do {
212             $self->edit_entry(
213             [ $feed->entries ], tag => $new, untag => $old
214             ) or return;
215             } while ($self->feed($feed));
216             }
217              
218             return 1;
219             }
220              
221             sub rename_tag {
222             my $self = shift;
223             return unless $self->rename_tag_feed(@_);
224             return unless $self->rename_tag_entry(@_);
225             return $self->delete_tags(shift);
226             }
227              
228             ## Edit feeds
229              
230             sub edit_feed {
231             my ($self, $sub, %params) = @_;
232              
233             $self->_login or return;
234             $self->_token or return;
235              
236             my $url = $self->host . EDIT_SUB_PATH;
237              
238             my %fields;
239             for my $s ('ARRAY' eq ref $sub ? @$sub : ($sub)) {
240             if (__PACKAGE__.'::Feed' eq ref $s) {
241             my $id = $s->id or next;
242             $id =~ s[^(?:user|webfeed|tag:google\.com,2005:reader/)][];
243             $id =~ s[\?.*][];
244             push @{$fields{s}}, $id;
245             }
246             else {
247             push @{$fields{s}}, _encode_type(feed => $s);
248             }
249             }
250             return 1 unless @{$fields{s} || []};
251              
252             if (defined(my $title = $params{title})) {
253             $fields{t} = $title;
254             }
255              
256             if (grep { exists $params{$_} } qw(subscribe add)) {
257             $fields{ac} = 'subscribe';
258             }
259             elsif (grep { exists $params{$_} } qw(unsubscribe remove)) {
260             $fields{ac} = 'unsubscribe';
261             }
262             else {
263             $fields{ac} = 'edit';
264             }
265              
266             # Add a tag or state.
267             for my $t (qw(tag state)) {
268             next unless exists $params{$t};
269             defined(my $p = $params{$t}) or next;
270             for my $a ('ARRAY' eq ref $p ? @$p : ($p)) {
271             push @{$fields{a}}, _encode_type($t => $a);
272             }
273             }
274             # Remove a tag or state.
275             for my $t (qw(untag unstate)) {
276             next unless exists $params{$t};
277             defined(my $p = $params{$t}) or next;
278             for my $d ('ARRAY' eq ref $p ? @$p : ($p)) {
279             push @{$fields{r}}, _encode_type(substr($t, 2) => $d);
280             }
281             }
282              
283             return $self->_edit($url, %fields);
284             }
285              
286             sub tag_feed { shift->edit_feed(shift, tag => \@_) }
287             sub untag_feed { shift->edit_feed(shift, untag => \@_) }
288             sub state_feed { shift->edit_feed(shift, state => \@_) }
289             sub unstate_feed { shift->edit_feed(shift, unstate => \@_) }
290             sub subscribe { shift->edit_feed(_listify(\@_), subscribe => 1) }
291             sub unsubscribe { shift->edit_feed(_listify(\@_), unsubscribe => 1) }
292             sub mark_read_feed { shift->mark_read(feed => _listify(\@_)) }
293             sub rename_feed { $_[0]->edit_feed($_[1], title => $_[2]) }
294              
295             ## Edit entries
296              
297             sub edit_entry {
298             my ($self, $entry, %params) = @_;
299             return unless $entry;
300              
301             $self->_login or return;
302             $self->_token or return;
303              
304             my %fields = (ac => 'edit');
305             for my $e ('ARRAY' eq ref $entry ? @$entry : ($entry)) {
306             my $source = $e->source or next;
307             my $stream_id = $source->get_attr('gr:stream-id') or next;
308             push @{$fields{i}}, $e->id;
309             push @{$fields{s}}, $stream_id;
310             }
311             return 1 unless @{$fields{i} || []};
312              
313             my $url = $self->host . EDIT_ENTRY_TAG_PATH;
314              
315             # Add a tag or state.
316             for my $t (qw(tag state)) {
317             next unless exists $params{$t};
318             defined(my $p = $params{$t}) or next;
319             for my $a ('ARRAY' eq ref $p ? @$p : ($p)) {
320             push @{$fields{a}}, _encode_type($t => $a);
321             }
322             }
323             # Remove a tag or state.
324             for my $t (qw(untag unstate)) {
325             next unless exists $params{$t};
326             defined(my $p = $params{$t}) or next;
327             for my $d ('ARRAY' eq ref $p ? @$p : ($p)) {
328             push @{$fields{r}}, _encode_type(substr($t, 2) => $d);
329             }
330             }
331              
332             return $self->_edit($url, %fields);
333             }
334              
335             sub tag_entry { shift->edit_entry(shift, tag => \@_) }
336             sub untag_entry { shift->edit_entry(shift, untag => \@_) }
337             sub state_entry { shift->edit_entry(shift, state => \@_) }
338             sub unstate_entry { shift->edit_entry(shift, unstate => \@_) }
339             sub share_entry { shift->edit_entry(_listify(\@_), state => 'broadcast') }
340             sub unshare_entry { shift->edit_entry(_listify(\@_), unstate => 'broadcast') }
341             sub star_entry { shift->edit_entry(_listify(\@_), state => 'starred') }
342             sub unstar_entry { shift->edit_entry(_listify(\@_), unstate => 'starred') }
343             sub mark_read_entry { shift->edit_entry(_listify(\@_), state => 'read') }
344             sub like_entry { shift->edit_entry(_listify(\@_), state => 'like') }
345             sub unlike_entry { shift->edit_entry(_listify(\@_), unstate => 'like') }
346              
347             # Create some aliases.
348             for (qw(star unstar like unlike)) {
349             no strict 'refs';
350             *$_ = \&{$_.'_entry'};
351             }
352              
353             ## Miscellaneous
354              
355             sub mark_read {
356             my ($self, %params) = @_;
357              
358             $self->_login or return;
359             $self->_token or return;
360              
361             my %fields;
362             my @types = grep { exists $params{$_} } qw(feed state tag);
363             for my $type (@types) {
364             push @{$fields{s}}, _encode_type($type, $params{$type});
365             }
366              
367             return $self->_edit($self->host . EDIT_MARK_READ_PATH, %fields);
368             }
369              
370             sub edit_preference {
371             my ($self, $key, $val) = @_;
372              
373             $self->_login or return;
374             $self->_token or return;
375              
376             return $self->_edit($self->host . EDIT_PREF_PATH, k => $key, v => $val);
377             }
378              
379             sub opml {
380             my ($self) = @_;
381              
382             $self->_login or return;
383              
384             my $res = $self->_request(GET($self->host . EXPORT_SUBS_PATH)) or return;
385              
386             return $res->decoded_content;
387             }
388              
389             sub ping {
390             my ($self) = @_;
391             my $res = $self->_request(GET($self->host . PING_PATH)) or return;
392              
393             return 1 if 'OK' eq $res->decoded_content;
394              
395             $self->error('Ping failed: '. $res->decoded_content);
396             return;
397             }
398              
399             ## Private interface
400              
401             sub _request {
402             my ($self, $req, $count) = @_;
403              
404             return if $count and 3 <= $count;
405              
406             # Assume all POST requests are secure.
407             if ('POST' eq $req->method) {
408             $req->uri->scheme('https');
409             }
410             elsif ('GET' eq $req->method and 'https' ne $req->uri->scheme) {
411             $req->uri->scheme($self->scheme);
412             }
413              
414             $req->uri->query_param(ck => time * 1000);
415             $req->uri->query_param(client => $self->ua->agent);
416              
417             $req->header(authorization => 'GoogleLogin auth=' . $self->auth)
418             if $self->auth;
419              
420             my $res = $self->ua->request($req);
421             $self->response($res);
422             if ($res->is_error) {
423             # Need fresh tokens.
424             if (401 == $res->code and $res->message =~ /^Token /) {
425             print "Stale Auth token- retrying\n" if $self->debug;
426             $self->_login(1) or return;
427             return $self->_request($req, ++$count);
428             }
429             elsif ($res->header('X-Reader-Google-Bad-Token')) {
430             print "Stale T token- retrying\n" if $self->debug;
431             $self->_token(1) or return;
432              
433             # Replace the T token in the url-encoded content.
434             my $uri = URI->new;
435             $uri->query($req->content);
436             $uri->query_param(T => $self->token);
437             $req->content($uri->query);
438              
439             return $self->_request($req, ++$count);
440             }
441              
442             $self->error(join ' - ',
443             'Request failed', $res->status_line, $res->header('title')
444             );
445             return;
446             }
447              
448             # Reset the error from previous requests.
449             $self->error(undef);
450              
451             return $res;
452             }
453              
454             sub _login {
455             my ($self, $force) = @_;
456              
457             return 1 if $self->_public;
458             return 1 if $self->auth and not $force;
459              
460             my $uri = URI->new($self->host . LOGIN_PATH);
461             $uri->query_form(
462             service => 'reader',
463             Email => $self->username,
464             Passwd => $self->password,
465             source => $self->ua->agent,
466             );
467             my $res = $self->_request(POST($uri)) or return;
468              
469             my $content = $res->decoded_content;
470             my ($auth) = $content =~ m[ ^Auth=(.*)$ ]mx;
471             unless ($auth) {
472             $self->error('Failed to find Auth token');
473             return;
474             }
475             $self->auth($auth);
476              
477             return 1;
478             }
479              
480             sub _token {
481             my ($self, $force) = @_;
482              
483             return 1 if $self->token and not $force;
484              
485             $self->_login($force) or return;
486              
487             my $uri = URI->new($self->host . TOKEN_PATH);
488             $uri->scheme('https');
489             my $res = $self->_request(GET($uri)) or return;
490              
491             return $self->token($res->decoded_content);
492             }
493              
494             sub _public {
495             return ! ($_[0]->username and $_[0]->password);
496             }
497              
498             sub _encode_type {
499             my ($type, $val, $escape) = @_;
500              
501             my @paths;
502             if ('feed' eq $type) { @paths = _encode_feed($val, $escape) }
503             elsif ('tag' eq $type) { @paths = _encode_tag($val) }
504             elsif ('state' eq $type) { @paths = _encode_state($val) }
505             elsif ('entry' eq $type) { @paths = _encode_entry($val) }
506             else { return }
507              
508             return wantarray ? @paths : shift @paths;
509             }
510              
511             sub _encode_feed {
512             my ($feed, $escape) = @_;
513              
514             my @paths;
515             for my $f ('ARRAY' eq ref $feed ? @$feed : ($feed)) {
516             my $path = ($escape ? uri_escape($f) : $f);
517             $path = "feed/$path"
518             if 'user/' ne substr $f, 0, 5 and 'feed/' ne substr $f, 0, 5;
519             push @paths, $path;
520             }
521              
522             return @paths;
523             }
524              
525             sub _encode_tag {
526             my ($tag) = @_;
527              
528             my @paths;
529             for my $t ('ARRAY' eq ref $tag ? @$tag : ($tag)) {
530             my $path = $t;
531             if ($t !~ m[ ^user/(?:-|\d{20})/ ]x) {
532             $path = "user/-/label/$t"
533             }
534             push @paths, $path;
535             }
536              
537             return @paths;
538             }
539              
540             sub _encode_state {
541             my ($state) = @_;
542              
543             my @paths;
544             for my $s ('ARRAY' eq ref $state ? @$state : ($state)) {
545             my $path = $s;
546             if ($s !~ m[ ^user/(?:-|\d{20})/ ]x) {
547             $path = "user/-/state/com.google/$s";
548             }
549             push @paths, $path;
550             }
551              
552             return @paths;
553             }
554              
555             sub _encode_entry {
556             my ($entry) = @_;
557              
558             my @paths;
559             for my $e ('ARRAY' eq ref $entry ? @$entry : ($entry)) {
560             my $path = $e;
561             if ('tag:google.com,2005:reader/item/' ne substr $e, 0, 32) {
562             $path = "tag:google.com,2005:reader/item/$e";
563             }
564             push @paths, $path;
565             }
566              
567             return @paths;
568             }
569              
570             sub _feed {
571             my ($self, $type, $val, %params) = @_;
572             return unless $val;
573              
574             $self->_login or return;
575              
576             my $path = $self->host . ($self->_public ? ATOM_PUBLIC_PATH : ATOM_PATH);
577             my $uri = URI->new($path . '/' . _encode_type($type, $val, 1));
578              
579             my %fields;
580             if (my $count = $params{count}) {
581             $fields{n} = $count;
582             }
583             if (my $start_time = $params{start_time}) {
584             $fields{ot} = $start_time;
585             }
586             if (my $order = $params{order} || $params{sort} || 'desc') {
587             # m = magic/auto; not really sure what that is
588             $fields{r} = 'desc' eq $order ? 'n' :
589             'asc' eq $order ? 'o' : $order;
590             }
591             if (defined(my $continuation = $params{continuation})) {
592             $fields{c} = $continuation;
593             }
594             if (my $ex = $params{exclude}) {
595             for my $x ('ARRAY' eq ref $ex ? @$ex : ($ex)) {
596             while (my ($xtype, $exclude) = each %$x) {
597             push @{$fields{xt}}, _encode_type($xtype, $exclude);
598             }
599             }
600             }
601              
602             $uri->query_form(\%fields);
603              
604             my $feed = (__PACKAGE__.'::Feed')->new(request => GET($uri), %params);
605             return $self->more($feed);
606             }
607              
608             sub _list {
609             my ($self, $url) = @_;
610              
611             $self->_login or return;
612              
613             my $uri = URI->new($url);
614             $uri->query_form({ $uri->query_form, output => 'json' });
615              
616             my $res = $self->_request(GET($uri)) or return;
617              
618             my $ref = eval { from_json($res->decoded_content) } or do {
619             $self->error("Failed to parse JSON response: $@");
620             return;
621             };
622              
623             # Remove an unecessary level of indirection.
624             my $aref = (grep { 'ARRAY' eq ref } values %$ref)[0] || [];
625              
626             for my $ref (@$aref) {
627             $ref = (__PACKAGE__.'::ListElement')->new($ref)
628             }
629              
630             return @$aref
631             }
632              
633             sub _edit {
634             my ($self, $url, %fields) = @_;
635             my $uri = URI->new($url);
636             my $req = POST($uri, [ %fields, T => $self->token ]);
637             my $res = $self->_request($req) or return;
638              
639             return 1 if 'OK' eq $res->decoded_content;
640              
641             # TODO: is there a standard error format which can be reliably parsed?
642             $self->error('Edit failed: '. $res->decoded_content);
643             return;
644             }
645              
646             sub _edit_tag {
647             my ($self, $type, $tag, %params) = @_;
648             return unless $tag;
649              
650             $self->_login or return;
651             $self->_token or return;
652              
653             my %fields = (s => [ _encode_type($type => $tag) ]);
654             return 1 unless @{$fields{s}};
655              
656             my $url;
657             if (grep { exists $params{$_} } qw(share public)) {
658             $url = $self->host . EDIT_TAG_SHARE_PATH;
659             $fields{pub} = 'true';
660             }
661             elsif (grep { exists $params{$_} } qw(unshare private)) {
662             $url = $self->host . EDIT_TAG_SHARE_PATH;
663             $fields{pub} = 'false';
664             }
665             elsif (grep { exists $params{$_} } qw(disable delete)) {
666             $url = $self->host . EDIT_TAG_DISABLE_PATH;
667             $fields{ac} = 'disable-tags';
668             }
669             else {
670             $self->error('Unknown action');
671             return;
672             }
673              
674             return $self->_edit($url, %fields);
675             }
676              
677             sub _states {
678             return qw(
679             read kept-unread fresh starred broadcast reading-list
680             tracking-body-link-used tracking-emailed tracking-item-link-used
681             tracking-kept-unread like
682             );
683             }
684              
685             sub _listify {
686             my ($aref) = @_;
687             return (1 == @$aref and 'ARRAY' eq ref $aref->[0]) ? @$aref : $aref;
688             }
689              
690              
691             1;
692              
693             __END__