package WebService::Google::Reader; use strict; use warnings; use base qw(Class::Accessor::Fast); use HTTP::Cookies; use HTTP::Request::Common qw(GET POST); use LWP::UserAgent; use JSON; use URI; use URI::Escape; use URI::QueryParam; use WebService::Google::Reader::Constants; use WebService::Google::Reader::Feed; use WebService::Google::Reader::ListElement; our $VERSION = '0.09'; $VERSION = eval $VERSION; __PACKAGE__->mk_accessors(qw( error password scheme token ua username )); sub new { my ($class, %params) = @_; my $self = bless \%params, $class; my $ua = $params{ua}; unless (ref $ua and $ua->isa(q(LWP::UserAgent))) { $ua = LWP::UserAgent->new( # Google only compresses content for certain agents or if gzip # is part of the agent name. agent => __PACKAGE__.'/'.$VERSION . (HAS_ZLIB ? ' (gzip)' :'') ); $self->ua($ua); } unless ($ua->cookie_jar) { $ua->cookie_jar(HTTP::Cookies->new(hide_cookie2 => 1)); } $self->scheme($params{secure} || $params{https} ? 'https' : 'http'); return $self; } ## Feeds sub feed { return shift->_feed(feed => shift, @_); } sub tag { return shift->_feed(tag => shift, @_); } sub state { return shift->_feed(state => shift, @_); } sub shared { return shift->state('broadcast', @_); } sub starred { return shift->state('starred', @_); } sub unread { return shift->state('reading-list', exclude => { state => 'read' }, @_); } sub search { my ($self, $query, %params) = @_; $self->_login or return; $self->_token or return; my $uri = URI->new(SEARCH_IDS_URL); my %fields; $fields{num} = $params{results} || 1000; my @types = grep { exists $params{$_} } qw(feed state tag); for my $type (@types) { push @{$fields{s}}, _encode_type($type, $params{$type}); } $uri->query_form({ q => $query, %fields, output => 'json' }); my $req = HTTP::Request->new(GET => $uri); my $res = $self->_request($req) or return; my @ids = do { my $ref = eval { decode_json($res->decoded_content(charset => 'none')) }; if ($@) { $self->error("Failed to parse JSON response: $@"); return; } map { $_->{id} } @{$ref->{results}}; }; return unless @ids; if (my $order = $params{order} || $params{sort}) { @ids = reverse @ids if 'asc' eq $order; } my $feed = (__PACKAGE__.'::Feed')->new( request => $req, ids => \@ids, count => $params{count} || 40, ); return $self->more($feed); } sub more { my ($self, $feed) = @_; my $req; if (defined $feed->ids) { my @ids = splice @{$feed->ids}, 0, $feed->count; return unless @ids; my $uri = URI->new(STREAM_IDS_CONTENT_URL, 'https'); $req = POST($uri, [ (map { ('i', $_) } @ids), T => $self->token ]); } elsif ($feed->elem) { return unless defined $feed->continuation and $feed->entries; $req = $feed->request; $req->uri->query_param(c => $feed->continuation); } elsif ($req = $feed->request) { # Initial request. } else { return } my $res = $self->_request($req) or return; $feed->init(Stream => $res->decoded_content(ref => 1)) or return; return $feed; } *previous = *next = \&more; ## Lists sub tags { return $_[0]->_list(LIST_TAGS_URL); } sub feeds { return $_[0]->_list(LIST_SUBS_URL); } sub preferences { return $_[0]->_list(LIST_PREFS_URL); } sub counts { return $_[0]->_list(LIST_COUNTS_URL); } sub userinfo { my ($self) = @_; return $_[0]->_list(LIST_USER_INFO_URL); } ## Edit tags sub edit_tag { return shift->_edit_tag(tag => @_); } sub edit_state { return shift->_edit_tag(state => @_); } sub share_tag { return shift->edit_tag(\@_, share => 1); } sub unshare_tag { return shift->edit_tag(\@_, unshare => 1); } sub share_state { return shift->edit_state(\@_, share => 1); } sub unshare_state { return shift->edit_state(\@_, unshare => 1); } sub delete_tag { return shift->edit_tag(\@_, delete => 1); } sub mark_read_tag { return shift->mark_read(tag => \@_); } sub mark_read_state { return shift->mark_read(state => \@_); } sub rename_feed_tag { my ($self, $old, $new) = @_; my @tagged; my @feeds = $self->feeds or return; # Get the list of subs which are associated with the tag to be renamed. FEED: for my $feed (@feeds) { for my $cat ($self->categories) { for my $o ('ARRAY' eq ref $old ? @$old : ($old)) { if ($old eq $cat->label or $old eq $cat->id) { push @tagged, $feed->id; next FEED; } } } } $_ = [ _encode_type(tag => $_) ] for ($old, $new); return $self->edit_feed(\@tagged, tag => $new, untag => $old); } sub rename_entry_tag { my ($self, $old, $new) = @_; for my $o ('ARRAY' eq ref $old ? @$old : ($old)) { my $feed = $self->tag($o) or return; do { $self->edit_entry([ $feed->entries ], tag => $new, untag => $old) or return; } while ($self->feed($feed)); } return 1; } sub rename_tag { my $self = shift; return unless $self->rename_tag_feed(@_); return unless $self->rename_tag_entry(@_); return $self->delete_tags(shift); } ## Edit feeds sub edit_feed { my ($self, $sub, %params) = @_; $self->_login or return; $self->_token or return; my $url = EDIT_SUB_URL; my %fields; for my $s ('ARRAY' eq ref $sub ? @$sub : ($sub)) { if (__PACKAGE__.'::Feed' eq ref $s) { my $id = $s->id or next; $id =~ s[^tag:google.com,2005:reader/][]; $id =~ s[\?.*][]; push @{$fields{s}}, $id; } else { push @{$fields{s}}, _encode_type(feed => $s); } } return 1 unless @{$fields{s} || []}; if (defined(my $title = $params{title})) { $fields{t} = $title; } if (grep { exists $params{$_} } qw(subscribe add)) { $fields{ac} = 'subscribe'; } elsif (grep { exists $params{$_} } qw(unsubscribe remove)) { $fields{ac} = 'unsubscribe'; } else { $fields{ac} = 'edit'; } # Add a tag or state. for my $t (qw(tag state)) { next unless exists $params{$t}; defined(my $p = $params{$t}) or next; for my $a ('ARRAY' eq ref $p ? @$p : ($p)) { push @{$fields{a}}, _encode_type($t => $a); } } # Remove a tag or state. for my $t (qw(untag unstate)) { next unless exists $params{$t}; defined(my $p = $params{$t}) or next; for my $d ('ARRAY' eq ref $p ? @$p : ($p)) { push @{$fields{r}}, _encode_type(substr($t, 2) => $d); } } return $self->_edit($url, %fields); } sub tag_feed { return shift->edit_feed(shift, tag => \@_); } sub untag_feed { return shift->edit_feed(shift, untag => \@_); } sub state_feed { return shift->edit_feed(shift, state => \@_); } sub unstate_feed { return shift->edit_feed(shift, unstate => \@_); } sub subscribe { return shift->edit_feed(\@_, subscribe => 1); } sub unsubscribe { return shift->edit_feed(\@_, unsubscribe => 1); } sub rename_feed { return $_[0]->edit_feed($_[1], title => $_[2]); } sub mark_read_feed { return shift->mark_read(feed => \@_); } ## Edit entries sub edit_entry { my ($self, $entry, %params) = @_; $self->_login or return; $self->_token or return; my %fields = (ac => 'edit'); for my $e ('ARRAY' eq ref $entry ? @$entry : ($entry)) { push @{$fields{i}}, $e->id; push @{$fields{s}}, $e->stream_id; } return 1 unless @{$fields{i} || []}; my $url = EDIT_ENTRY_TAG_URL; # Add a tag or state. for my $t (qw(tag state)) { next unless exists $params{$t}; defined(my $p = $params{$t}) or next; for my $a ('ARRAY' eq ref $p ? @$p : ($p)) { push @{$fields{a}}, _encode_type($t => $a); } } # Remove a tag or state. for my $t (qw(untag unstate)) { next unless exists $params{$t}; defined(my $p = $params{$t}) or next; for my $d ('ARRAY' eq ref $p ? @$p : ($p)) { push @{$fields{r}}, _encode_type(substr($t, 2) => $d); } } return $self->_edit($url, %fields); } sub tag_entry { return shift->edit_entry(shift, tag => \@_); } sub untag_entry { return shift->edit_entry(shift, untag => \@_); } sub state_entry { return shift->edit_entry(shift, state => \@_); } sub unstate_entry { return shift->edit_entry(shift, unstate => \@_); } sub share_entry { return shift->edit_entry(shift, state => 'broadcast'); } sub unshare_entry { return shift->edit_entry(shift, unstate => 'broadcast'); } sub star_entry { return shift->edit_entry(shift, state => 'starred'); } *star = \&star_entry; sub unstar_entry { return shift->edit_entry(shift, unstate => 'starred'); } *unstar = \&unstar_entry; sub mark_read_entry { return shift->edit_entry(\@_, state => 'read'); } ## Miscellaneous sub mark_read { my ($self, %params) = @_; $self->_login or return; $self->_token or return; my %fields; my @types = grep { exists $params{$_} } qw(feed state tag); for my $type (@types) { push @{$fields{s}}, _encode_type($type, $params{$type}); } return $self->_edit(EDIT_MARK_READ_URL, %fields); } sub edit_preference { my ($self, $key, $val) = @_; $self->_login or return; $self->_token or return; return $self->_edit(EDIT_PREF_URL, k => $key, v => $val); } sub opml { my ($self) = @_; $self->_login or return; my $req = GET(EXPORT_SUBS_URL); my $res = $self->_request($req) or return; return $res->decoded_content; } sub ping { my ($self, %fields) = @_; my $res = $self->_request(GET(PING_URL)) or return; return 1 if 'OK' eq $res->decoded_content; $self->error('Ping failed: '. $res->decoded_content); return; } ## Private interface sub _login { my ($self, $force) = @_; return 1 if $self->_public; return 1 if not $force and $self->_cookie; my $uri = URI->new(LOGIN_URL); $uri->query_form( service => 'reader', Email => $self->username, Passwd => $self->password, source => $self->ua->agent, continue => READER_URL, ); my $res = $self->ua->post($uri); my $content = $res->decoded_content; if ($res->is_error) { my ($err) = $content =~ m[ ^Error=(.*)$ ]mx; $self->error($res->status_line . ($err ? (': '. $err) : '')); return; } my ($sid) = $content =~ m[ ^SID=(.*)$ ]mx; unless ($sid) { $self->error('could not find SID value for cookie'); return; } $self->ua->cookie_jar->set_cookie( 0, SID => $sid, '/', '.google.com', undef, 1, 0, 160000000000 ); return 1; } sub _request { my ($self, $req, $count) = @_; return if $count and 2 <= $count; # Assume all POST requests are secure. $req->uri->scheme($self->scheme) if 'GET' eq $req->method; $req->uri->query_param(ck => time * 1000); $req->uri->query_param(client => $self->ua->agent); print $req->as_string, "-"x80, "\n" if DEBUG; if (HAS_ZLIB) { $req->header(accept_encoding => 'gzip,deflate'); # Doesn't always work; gets 415- unsupported media type for some urls. #if (my $content = $req->content) { # if ($content = Compress::Zlib::memGzip($content)) { # $req->content($content); # $req->content_length(length $content); # $req->content_encoding('gzip'); # } #} } my $res = $self->ua->request($req); if ($res->is_error) { # Need a fresh token. if ($res->header('X-Reader-Google-Bad-Token')) { print "Stale token- retrying\n" if DEBUG; $self->_token(1) or return; return $self->_request($req, $count++); } $self->error($res->status_line . ' - ' . $res->decoded_content); return; } return $res; } # NOTE: any request that sends the token, should use https. sub _token { my ($self, $force) = @_; return 1 if $self->token and not $force; $self->_login or return; my $uri = URI->new(TOKEN_URL, 'https'); my $res = $self->_request(GET($uri)) or return; return $self->token($res->decoded_content); } sub _public { return not $_[0]->username or not $_[0]->password; } sub _cookie { # ick, HTTP::Cookies doesn't provide an accessor. return $_[0]->ua->cookie_jar->{COOKIES}{'.google.com'}{'/'}{SID}; } sub _encode_type { my ($type, $val, $escape) = @_; my @paths; if ('feed' eq $type) { @paths = _encode_feed($val, $escape); } elsif ('tag' eq $type) { @paths = _encode_tag($val); } elsif ('state' eq $type) { @paths = _encode_state($val); } elsif ('entry' eq $type) { @paths = _encode_entry($val); } else { return; } return wantarray ? @paths : shift @paths; } sub _encode_feed { my ($feed, $escape) = @_; my @paths; for my $f ('ARRAY' eq ref $feed ? @$feed : ($feed)) { my $path = $f; if ('feed/' ne substr $f, 0, 5) { $path = 'feed/' . ($escape ? uri_escape($f) : $f); } push @paths, $path; } return @paths; } sub _encode_tag { my ($tag) = @_; my @paths; for my $t ('ARRAY' eq ref $tag ? @$tag : ($tag)) { my $path = $t; if ($t !~ m[ ^user/(?:-|\d{20})/ ]x) { $path = "user/-/label/$t" } push @paths, $path; } return @paths; } sub _encode_state { my ($state) = @_; my @paths; for my $s ('ARRAY' eq ref $state ? @$state : ($state)) { my $path = $s; if ($s !~ m[ ^user/(?:-|\d{20})/ ]x) { $path = "user/-/state/com.google/$s"; } push @paths, $path; } return @paths; } sub _encode_entry { my ($entry) = @_; my @paths; for my $e ('ARRAY' eq ref $entry ? @$entry : ($entry)) { my $path = $e; if ('tag:google.com,2005:reader/item/' ne substr $e, 0, 32) { $path = "tag:google.com,2005:reader/item/$e"; } push @paths, $path; } return @paths; } sub _feed { my ($self, $type, $val, %params) = @_; $self->_login or return; my $path = $self->_public ? ATOM_PUBLIC_URL : ATOM_URL; my $uri = URI->new($path . _encode_type($type, $val, 1)); my %fields; if (my $count = $params{count}) { $fields{n} = $count; } if (my $start_time = $params{start_time}) { $fields{ot} = $start_time; } if (my $order = $params{order} || $params{sort}) { # m = magic/auto; not really sure what that is $fields{r} = 'desc' eq $order ? 'n' : 'asc' eq $order ? 'o' : $order; } if (defined(my $continuation = $params{continuation})) { $fields{c} = $continuation; } if (my $ex = $params{exclude}) { for my $x ('ARRAY' eq ref $ex ? @$ex : ($ex)) { while (my ($xtype, $exclude) = each %$x) { push @{$fields{xt}}, _encode_type($xtype, $exclude); } } } $uri->query_form(\%fields); my $feed = (__PACKAGE__.'::Feed')->new(request => GET($uri)); return $self->more($feed); } sub _list { my ($self, $url) = @_; $self->_login or return; my $uri = URI->new($url); $uri->query_form({ $uri->query_form, output => 'json' }); my $res = $self->_request(GET($uri)) or return; my $ref = eval { decode_json($res->decoded_content(charset=>'none')) }; if ($@) { $self->error("Failed to parse JSON response: $@"); return; } # Remove an unecessary level of indirection. my $aref = (grep { 'ARRAY' eq ref } values %$ref)[0] || []; for my $ref (@$aref) { $ref = (__PACKAGE__.'::ListElement')->new($ref) } return @$aref } sub _edit { my ($self, $url, %fields) = @_; my $uri = URI->new($url, 'https'); my $req = POST($uri, [ %fields, T => $self->token ]); my $res = $self->_request($req) or return; return 1 if 'OK' eq $res->decoded_content; # TODO: is there a standard error format which can be reliably parsed? $self->error('Edit failed: '. $res->decoded_content); return; } sub _edit_tag { my ($self, $type, $tag, %params) = @_; $self->_login or return; $self->_token or return; my %fields; push @{$fields{s}}, _encode_type($type => $tag); return 1 unless @{$fields{s} || []}; my $url; if (grep { exists $params{$_} } qw(share public)) { $url = EDIT_TAG_SHARE_URL; $fields{pub} = 'true'; } elsif (grep { exists $params{$_} } qw(unshare private)) { $url = EDIT_TAG_SHARE_URL; $fields{pub} = 'false'; } elsif (grep { exists $params{$_} } qw(disable delete)) { $url = EDIT_TAG_DISABLE_URL; $fields{ac} = 'disable-tags'; } else { $self->error('Unknown action'); return; } return $self->_edit($url, %fields); } sub _states { return qw( read kept-unread fresh starred broadcast reading-list tracking-body-link-used tracking-emailed tracking-item-link-used tracking-kept-unread ); } 1; __END__ =head1 NAME WebService::Google::Reader - Perl interface to Google Reader =head1 SYNOPSIS use WebService::Google::Reader; my $reader = WebService::Google::Reader->new( username => $user, password => $pass, ); my $feed = $reader->unread(count => 100); my @entries = $feed->entries; # Fetch past entries. while ($reader->more($feed)) { my @entries = $feed->entries; } =head1 DESCRIPTION The C module provides an interface to the Google Reader service through the unofficial (as-yet unpublished) API. =head1 METHODS =over =item $reader = WebService::Google::Reader->B Creates a new WebService::Google::Reader object. The following named parameters are accepted: =over =item B and B Required for accessing any personalized or account-related functionality (reading-list, editing, etc.). =item B / B Use https scheme for all requests, even when not required. =item B An optional useragent object. =back =item $error = $reader->B Returns the error, if one occurred. =back =head2 Feed generators The following methods request an ATOM feed and return a subclass of C. These methods accept the following optional named parameters: =over =over =item B / B The sort order of the entries: B (default) or B in time. When ordering by B, Google only returns entries within 30 days, whereas the default order has no limitation. =item B Request entries only newer than this time (represented as a unix timestamp). =item B(feed => $feed|[@feeds], tag => $tag|[@tags]) Accepts a hash reference to one or more of feed / tag / state. Each of which is a scalar or array reference. =back =back =over =item B($feed) Accepts a single feed url. =item B($tag) Accepts a single tag name. See L =item B($state) Accepts a single state name. See L. =item B Shortcut for B('broadcast'). =item B Shortcut for B('starred'). =item B Shortcut for B('reading-list', exclude => { state => 'read' }) =back =over =item B($query, %params) Accepts a query string and the following named parameters: =over =item B / B / B One or more (as a array reference) feed / state / tag to search. The default is to search all feed subscriptions. =item B The total number of search results: defaults to 1000. =item B The number of entries per fetch: defaults to 40. =item B / B The sort order of the entries: B (default) or B in time. =back =item B / B / B A feed generator only returns B<$count> entries. If more are available, calling this method will return a feed with the next B<$count> entries. =back =head2 List generators The following methods return an object of type C. =over =item B Returns a list of subscriptions and a count of unread entries. Also listed are any tags or states which have positive unread counts. The following accessors are provided: id, count. The maximum count reported is 1000. =item B Returns the list of user subscriptions. The following accessors are provided: id, title, categories, firstitemmsec. categories is a reference to a list of Cs providing accessors: id, label. =item B Returns the list of preference settings. The following accessors are provided: id, value. =item B Returns the list of user-created tags. The following accessors are provided: id, shared. =item B Returns the list of user information. The following accessors are provided: isBloggerUser, userId, userEmail. =back =head2 Edit feeds The following methods are used to edit feed subscriptions. =over =item B($feed|[@feeds], %params) Requires a feed url or Feed object, or a reference to a list of them. The following named parameters are accepted: =over =item B / B Flag indicating whether the target feeds should be added or removed from the user's subscriptions. =item B Accepts a title to associate with the feed. This probaby wouldn't make sense to use when there are multiple feeds. (Maybe later will consider allowing a list here and zipping the feed and title lists). =item B<tag> / B<state> / B<untag> / B<unstate> Accepts a tag / state or a reference to a list of tags / states for which to associate / unassociate the target feeds. =back =item B<tag_feed>($feed|[@feeds], @tags) =item B<untag_feed>($feed|[@feeds], @tags) =item B<state_feed>($feed|[@feeds], @states) =item B<unstate_feed>($feed|[@feeds], @states) Associate / unassociate a list of tags / states from a feed / feeds. =item B<subscribe>(@feeds) =item B<unsubscribe>(@feeds) Subscribe or unsubscribe from a list of feeds. =item B<rename_feed>($feed|[@feeds], $title) Renames a feed to the given title. =item B<mark_read_feed>(@feeds) Marks the feeds as read. =back =head2 Edit tags / states The following methods are used to edit tags and states. =over =item B<edit_tag>($tag|[@tags], %params) =item B<edit_state>($state|[@states], %params) Accepts the following parameters. =over =item B<share> / B<public> Make the given tags / states public. =item B<unshare> / B<private> Make the given tags / states private. =item B<disable> / B<delete> Only tags (and not states) can be disabled. =back =item B<share_tag>(@tags) =item B<unshare_tag>(@tags) =item B<share_state>(@states) =item B<unshare_state>(@states) Associate / unassociate the 'broadcast' state with the given tags / states. =item B<delete_tag>(@tags) Delete the given tags. =item B<rename_feed_tag>($oldtag|[@oldtags], $newtag|[@newtags] Renames the tags associated with any feeds. =item B<rename_entry_tag>($oldtag|[@oldtags], $newtag|[@newtags] Renames the tags associated with any individual entries. =item B<rename_tag>($oldtag|[@oldtags], $newtag|[@newtags] Calls B<rename_feed_tag> and B<rename_entry_tag>, and finally B<delete_tag>. =item B<mark_read_tag>(@tags) =item B<mark_read_state>(@states) Marks all entries as read for the given tags / states. =back =head2 Edit entries The following methods are used to edit individual entries. =over =item B<edit_entry>($entry|[@entries], %params) =over =item B<tag> / B<state> / B<untag> / B<unstate> Associate / unassociate the entries with the given tags / states. =back =item B<tag_entry>($entry|[@entries], @tags) =item B<untag_entry>($entry|[@entries], @tags) =item B<state_entry>($entry|[@entries], @tags) =item B<unstate_entry>($entry|[@entries], @tags) Associate / unassociate the entries with the given tags / states. =item B<share_entry>(@entries) =item B<unshare_entry>(@entries) Marks all the given entries as "broadcast". =item B<star> =item B<star_entry> =item B<unstar> =item B<unstar_entry> Marks / unmarks all the given entries as "starred". =item B<mark_read_entry>(@entries) Marks all the given entries as "read". =back =head2 Miscellaneous These are a list of other useful methods. =over =item B<edit_preference>($key, $value) Sets the given preference name to the given value. =item B<mark_read>(feed => $feed|[@feeds], state => $state|[@states], tag => $tag|[@tags]) =item B<opml> Exports feed subscriptions as OPML. =item B<ping> Returns true / false on success / failure. Unsure of when this needs to be used. =back =head2 Private methods The following private methods may be of use to others. =over =item B<_login> This is automatically called from within methods that require authorization. An optional parameter is accepted which when true, will force a login even if a previous login was successful. The end result of a successful login is to set the SID cookie. =item B<_request> Given an C<HTTP::Request>, this will perform the request and if the response indicates a bad (expired) token, it will request another token before performing the request again. Returns an C<HTTP::Response> on success, false on failure (check B<error>). =item B<_token> This is automatically called from within methods that require a user token. If successful, the token is available via the B<token> accessor. =item B<_states> Returns a list of all the known states. See L</STATES>. =back =head1 TAGS The following characters are not allowed: "E<lt>E<gt>?&/\^ =head1 STATES These are tags in a Google-specific namespace. The following are all the known used states. =over =item read Entries which have been read. =item kept-unread Entries which have been read, but marked unread. =item fresh New entries from reading-list. =item starred Entries which have been starred. =item broadcast Entries which have been shared and made publicly available. =item reading-list Entries from all subscriptions. =item tracking-body-link-used Entries for which a link in the body has been clicked. =item tracking-emailed Entries which have been mailed. =item tracking-item-link-used Entries for which the title link has been clicked. =item tracking-kept-unread Entries which have been kept unread. (Not sure how this differs from "kept-unread"). =back =head1 NOTES If C<Compress::Zlib> is found, then requests will accept compressed responses. =head1 SEE ALSO L<XML::Atom::Feed> L<http://code.google.com/p/pyrfeed/wiki/GoogleReaderAPI> =head1 REQUESTS AND BUGS Please report any bugs or feature requests to L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=WebService-Google-Reader>. I will be notified, and then you'll automatically be notified of progress on your bug as I make changes. =head1 SUPPORT You can find documentation for this module with the perldoc command. perldoc WebService::Google::Reader You can also look for information at: =over =item * AnnoCPAN: Annotated CPAN documentation L<http://annocpan.org/dist/WebService-Google-Reader> =item * CPAN Ratings L<http://cpanratings.perl.org/d/WebService-Google-Reader> =item * RT: CPAN's request tracker L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=WebService-Google-Reader> =item * Search CPAN L<http://search.cpan.org/dist/WebService-Google-Reader> =back =head1 COPYRIGHT AND LICENSE Copyright (C) 2007-2009 gray <gray at cpan.org>, all rights reserved. This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself. =head1 AUTHOR gray, <gray at cpan.org> =cut