File Coverage

blib/lib/WWW/Plurk.pm
Criterion Covered Total %
statement 82 215 38.1
branch 6 62 9.6
condition 0 41 0.0
subroutine 23 52 44.2
pod 10 10 100.0
total 121 380 31.8


line stmt bran cond sub pod time code
1             package WWW::Plurk;
2              
3 4     4   85980 use warnings;
  4         10  
  4         168  
4 4     4   21 use strict;
  4         8  
  4         137  
5              
6 4     4   24 use Carp;
  4         11  
  4         386  
7 4     4   4091 use DateTime::Format::Mail;
  4         1044662  
  4         167  
8 4     4   5398 use HTML::Tiny;
  4         13214  
  4         141  
9 4     4   5461 use HTTP::Cookies;
  4         94783  
  4         145  
10 4     4   4637 use JSON;
  4         79932  
  4         26  
11 4     4   7714 use Data::Dumper;
  4         34636  
  4         375  
12 4     4   6562 use LWP::UserAgent;
  4         265738  
  4         151  
13 4     4   4726 use Time::Piece;
  4         55148  
  4         29  
14 4     4   3044 use WWW::Plurk::Friend;
  4         10  
  4         105  
15 4     4   2352 use WWW::Plurk::Message;
  4         19  
  4         248  
16              
17             =head1 NAME
18              
19             WWW::Plurk - Unoffical plurk.com API
20              
21             =head1 VERSION
22              
23             This document describes WWW::Plurk version 0.02
24              
25             =cut
26              
27             our $VERSION = '0.02';
28              
29             =head1 SYNOPSIS
30              
31             use WWW::Plurk;
32             my $plurk = WWW::Plurk->new;
33             $plurk->login( 'username', 'password' );
34             my $msg = $plurk->add_plurk( content => 'Hello, World' );
35              
36             =head1 DESCRIPTION
37              
38             This is an unofficial API for plurk.com. It uses the same interfaces
39             that plurk itself uses internally which are not published and not
40             necessarily stable. When Plurk publish a stable API this module will be
41             updated to take advantage of it. In the mean time use with caution.
42              
43             Ryan Lim did the heavy lifting of reverse engineering the API. His PHP
44             implementation can be found at L.
45              
46             If you'd like to lend a hand supporting the bits of Plurk that this API
47             doesn't yet reach please feel free to send me a patch. The Plurk API
48             Wiki at L is a good source of
49             information.
50              
51             =cut
52              
53             # Default API URIs
54              
55 4     4   33 use constant MAX_MESSAGE_LENGTH => 140;
  4         9  
  4         638  
56              
57             my $BASE_DEFAULT = 'http://www.plurk.com';
58              
59             my %PATH_DEFAULT = (
60             accept_friend => '/Notifications/allow',
61             add_plurk => '/TimeLine/addPlurk',
62             add_response => '/Responses/add',
63             deny_friend => '/Notifications/deny',
64             get_completion => '/Users/getCompletion',
65             get_friends => '/Users/getFriends',
66             get_plurks => '/TimeLine/getPlurks',
67             get_responses => '/Responses/get2',
68             get_unread_plurks => '/TimeLine/getUnreadPlurks',
69             home => undef,
70             login => '/Users/login?redirect_page=main',
71             notifications => '/Notifications',
72             );
73              
74             BEGIN {
75 4     4   14 my @ATTR = qw(
76             _base_uri
77             info
78             state
79             trace
80             );
81              
82 4         17 my @INFO = qw(
83             display_name
84             full_name
85             gender
86             has_profile_image
87             id
88             is_channel
89             karma
90             location
91             nick_name
92             page_title
93             relationship
94             star_reward
95             uid
96             );
97              
98 4         18 for my $attr ( @ATTR ) {
99 4     4   24 no strict 'refs';
  4         8  
  4         409  
100 16         82 *{$attr} = sub {
101 11     11   16 my $self = shift;
102 11 50       142 return $self->{$attr} unless @_;
103 0         0 return $self->{$attr} = shift;
104 16         49 };
105             }
106              
107 4         12 for my $info ( @INFO ) {
108 4     4   22 no strict 'refs';
  4         15  
  4         313  
109 52         12878 *{$info} = sub {
110 0     0   0 my $self = shift;
111             # Info attributes only available when logged in
112 0         0 $self->_logged_in;
113 0         0 return $self->info->{$info};
114 52         258 };
115             }
116             }
117              
118             =head1 INTERFACE
119              
120             All methods throw errors in the event of any kind of failure. There's no
121             need to check return values but you might want to wrap calls in an
122             C block.
123              
124             =head2 C<< new >>
125              
126             Create a new C<< WWW::Plurk >>. Optionally accepts two arguments
127             (username, password). If they are supplied it will attempt to login to
128             Plurk. If no arguments are supplied C must be called before
129             attempting to access the service.
130              
131             # Create and login
132             my $plurk = WWW::Plurk->new( 'user', 'pass' );
133            
134             # Create then login afterwards
135             my $plurk = WWW::Plurk->new;
136             $plurk->login( 'user', 'pass' );
137              
138             =cut
139              
140             sub new {
141 1     1 1 28 my $class = shift;
142 1 50       22 my $self = bless {
143             _base_uri => $BASE_DEFAULT,
144             path => {%PATH_DEFAULT},
145             state => 'init',
146             trace => $ENV{PLURK_TRACE} ? 1 : 0,
147             }, $class;
148              
149 1 50       5 if ( @_ ) {
150 0 0       0 croak "Need two arguments (user, pass) if any are supplied"
151             unless @_ == 2;
152 0         0 $self->login( @_ );
153             }
154              
155 1         4 return $self;
156             }
157              
158             sub _make_ua {
159 0     0   0 my $self = shift;
160 0         0 my $ua = LWP::UserAgent->new;
161 0         0 $ua->agent( join ' ', __PACKAGE__, $VERSION );
162 0         0 $ua->cookie_jar( HTTP::Cookies->new );
163 0         0 return $ua;
164             }
165              
166             sub _ua {
167 0     0   0 my $self = shift;
168 0   0     0 return $self->{_ua} ||= $self->_make_ua;
169             }
170              
171             sub _trace {
172 0     0   0 my ( $self, @msgs ) = @_;
173 0 0       0 if ( $self->trace ) {
174 0         0 print STDERR "$_\n" for @msgs;
175             }
176             }
177              
178             sub _raw_post {
179 0     0   0 my ( $self, $uri, $params ) = @_;
180 0         0 $self->_trace(
181             POST => $uri,
182             Data::Dumper->Dump( [$params], [qw($params)] )
183             );
184 0         0 my $resp = $self->_ua->post( $uri, $params );
185 0         0 $self->_trace( $resp->status_line );
186 0         0 return $resp;
187             }
188              
189             sub _raw_get {
190 0     0   0 my ( $self, $uri ) = @_;
191 0         0 $self->_trace( GET => $uri );
192 0         0 my $resp = $self->_ua->get( $uri );
193 0         0 $self->_trace( $resp->status_line );
194 0         0 return $resp;
195             }
196              
197 0     0   0 sub _cookies { shift->_ua->cookie_jar }
198              
199             sub _post {
200 0     0   0 my ( $self, $service, $params ) = @_;
201 0   0     0 my $resp
202             = $self->_raw_post( $self->_uri_for( $service ), $params || {} );
203 0 0 0     0 croak $resp->status_line
204             unless $resp->is_success
205             or $resp->is_redirect;
206 0         0 return $resp;
207             }
208              
209             sub _json_post {
210 0     0   0 my $self = shift;
211 0         0 return $self->_decode_json( $self->_post( @_ )->content );
212             }
213              
214             sub _get {
215 0     0   0 my ( $self, $service, $params ) = @_;
216 0   0     0 my $resp
217             = $self->_raw_get( $self->_uri_for( $service, $params || {} ) );
218 0 0 0     0 croak $resp->status_line
219             unless $resp->is_success
220             or $resp->is_redirect;
221 0         0 return $resp;
222             }
223              
224             sub _json_get {
225 0     0   0 my $self = shift;
226 0         0 return $self->_decode_json( $self->_get( @_ )->content );
227             }
228              
229             =head2 C<< login >>
230              
231             Attempt to login to a Plurk account. The two mandatory arguments are the
232             username and password for the account to be accessed.
233              
234             my $plurk = WWW::Plurk->new;
235             $plurk->login( 'user', 'pass' );
236              
237             =cut
238              
239             sub login {
240 0     0 1 0 my ( $self, $name, $pass ) = @_;
241              
242 0         0 my $resp = $self->_post(
243             login => {
244             nick_name => $name,
245             password => $pass,
246             }
247             );
248              
249 0         0 my $ok = 0;
250 0 0   0   0 $self->_cookies->scan( sub { $ok++ if $_[1] eq 'plurkcookie' } );
  0         0  
251 0 0       0 croak "Login for $name failed, no cookie returned"
252             unless $ok;
253              
254 0   0     0 $self->_path_for( home => $resp->header( 'Location' )
255             || "/user/$name" );
256              
257 0         0 $self->_parse_user_home;
258 0         0 $self->state( 'login' );
259             }
260              
261             sub _parse_time {
262 1     1   4 my ( $self, $time ) = @_;
263 1         13 return DateTime::Format::Mail->parse_datetime( $time )->epoch;
264             }
265              
266             # This is a bit of a bodge. Plurk doesn't return pure JSON; instead it
267             # returns JavaScript that's nearly JSON apart from the fact that
268             # timestamps are specified as 'new Date("...")'. So we need to hoist
269             # those out of the text and replace them with the corresponding epoch
270             # timestamp.
271             #
272             # Theoretically we could just do a search and replace. Because the Date
273             # constructor contains a quoted string there's no danger of false
274             # positives when someone happens to post a message that contains
275             # matching text - because in that case the nested quotes would be
276             # backslashed and the regex wouldn't match.
277             #
278             # Of course that didn't occur to me until /after/ I'd written the code
279             # to pull all the string literals out of the text before replacing the
280             # Date constructors...
281             #
282             # I'll leave that code in place because it's useful to have lying around
283             # in case some future version of this routine has to handle embedded JS
284             # that could collide with the contents of string literals.
285              
286             sub _decode_json {
287 1     1   585 my ( $self, $json ) = @_;
288              
289 1         3 my %strings = ();
290 1         3 my $next_token = 1;
291              
292             my $tok = sub {
293 2     2   5 my $str = shift;
294 2         7 my $key = sprintf '#%d#', $next_token++;
295 2         5 $strings{$key} = $str;
296 2         11 return qq{"$key"};
297 1         6 };
298              
299             # Stash string literals to avoid false positives
300 1         11 $json =~ s{ " ( (?: \\. | [^\\"]+ )* ) " }{ $tok->( $1 ) }xeg;
  2         6  
301              
302             # Plurk actually returns JS rather than JSON.
303 1         8 $json =~ s{ new \s+ Date \s* \( \s* " (\#\d+\#) " \s* \) }
304 1         5 { $self->_parse_time( $strings{$1} ) }xeg;
305              
306             # Replace string literals
307 1         715 $json =~ s{ " (\#\d+\#) " }{ qq{"$strings{$1}"} }xeg;
  1         6  
308              
309             # Now we have JSON
310 1         22 return decode_json $json;
311             }
312              
313             sub _parse_user_home {
314 0     0   0 my $self = shift;
315 0         0 my $resp = $self->_get( 'home' );
316 0 0       0 if ( $resp->content =~ /^\s*var\s+GLOBAL\s*=\s*(.+)$/m ) {
317 0         0 my $global = $self->_decode_json( $1 );
318 0   0     0 $self->info(
319             $global->{session_user}
320             or croak "No session_user data found"
321             );
322             }
323             else {
324 0         0 croak "Can't find GLOBAL data on user page";
325             }
326             }
327              
328             =head2 C<< is_logged_in >>
329              
330             Returns a true value if we're currently logged in.
331              
332             if ( $plurk->is_logged_in ) {
333             $plurk->add_plurk( content => 'w00t!' );
334             }
335              
336             =cut
337              
338 0     0 1 0 sub is_logged_in { shift->state eq 'login' }
339              
340             sub _logged_in {
341 0     0   0 my $self = shift;
342 0 0       0 croak "Please login first"
343             unless $self->is_logged_in;
344             }
345              
346             =head2 C<< friends_for >>
347              
348             Return a user's friends.
349              
350             my @friends = $plurk->friends_for( $uid );
351              
352             Pass the user id as either
353              
354             =over
355              
356             =item * an integer
357              
358             my @friends = $plurk->friends_for( 12345 );
359              
360             =item * an object that has a method called C
361              
362             # $some_user isa WWW::Plurk::Friend
363             my @friends = $plurk->friends_for( $some_user );
364              
365             =back
366              
367             Returns a list of L objects.
368              
369             =cut
370              
371             sub friends_for {
372 0     0 1 0 my $self = shift;
373 0   0     0 my $for = $self->_uid_cast( shift || $self );
374 0         0 $self->_logged_in;
375 0         0 my $friends
376             = $self->_json_get( get_completion => { user_id => $for } );
377 0         0 return map { WWW::Plurk::Friend->new( $self, $_, $friends->{$_} ) }
  0         0  
378             keys %$friends;
379             }
380              
381             =head2 C<< friends >>
382              
383             Return the current user's friends. This
384              
385             my @friends = $plurk->friends;
386              
387             is equivalent to
388              
389             my @friends = $plurk->friends_for( $self->uid );
390              
391             =cut
392              
393             sub friends {
394 0     0 1 0 my $self = shift;
395 0         0 return $self->friends_for( $self );
396             }
397              
398             =head2 C<< add_plurk >>
399              
400             Post a new plurk.
401              
402             $plurk->add_plurk(
403             content => 'Hello, World'
404             );
405              
406             Arguments are supplied as a number of key, value pairs. The following
407             arguments are recognised:
408              
409             =over
410              
411             =item * content - the message content
412              
413             =item * qualifier - the qualifier string ('is', 'says' etc)
414              
415             =item * lang - the (human) language for this Plurk
416              
417             =item * no_comments - true to disallow comments
418              
419             =item * limited_to - limit visibility
420              
421             =back
422              
423             The only mandatory argument is C which should be a string of
424             140 characters or fewer.
425              
426             C is first word of the message - which has special
427             significance that you will understand if you have looked at the Plurk
428             web interface. The following qualifiers are supported:
429              
430             asks feels gives has hates is likes loves
431             says shares thinks wants was will wishes
432              
433             If omitted C defaults to ':' which signifies that you are
434             posting a free-form message with no qualifier.
435              
436             C is the human language for this Plurk. It defaults to 'en'.
437             Apologies to those posting in languages other than English.
438              
439             C should be true to lock the Plurk preventing comments from
440             being made.
441              
442             C is an array of user ids (or objects with a method called
443             C). If present the Plurk will only be visible to those users. To
444             limit visibility of a Plurk to friends use:
445              
446             my $msg = $plurk->add_plurk(
447             content => 'Hi chums',
448             limited_to => [ $plurk->friends ]
449             );
450              
451             Returns a L representing the new Plurk.
452              
453             =cut
454              
455             sub _is_user {
456 0     0   0 my ( $self, $obj ) = @_;
457 0   0     0 return UNIVERSAL::can( $obj, 'can' ) && $obj->can( 'uid' );
458             }
459              
460             sub _uid_cast {
461 0     0   0 my ( $self, $obj ) = @_;
462 0 0       0 return $self->_is_user( $obj ) ? $obj->uid : $obj;
463             }
464              
465             sub _msg_common {
466 0     0   0 my ( $self, $cb, @args ) = @_;
467              
468 0 0       0 croak "Needs a number of key => value pairs"
469             if @args & 1;
470 0         0 my %args = @args;
471              
472 0   0     0 my $content = delete $args{content} || croak "Must have content";
473 0   0     0 my $lang = delete $args{lang} || 'en';
474 0   0     0 my $qualifier = delete $args{qualifier} || ':';
475              
476 0         0 my @extras = $cb->( \%args );
477              
478 0 0       0 if ( my @unknown = sort keys %args ) {
479 0         0 croak "Unknown parameter(s): ", join ',', @unknown;
480             }
481              
482 0 0       0 if ( length $content > MAX_MESSAGE_LENGTH ) {
483 0         0 croak 'Plurks are limited to '
484             . MAX_MESSAGE_LENGTH
485             . ' characters';
486             }
487              
488 0         0 return ( $content, $lang, $qualifier, @extras );
489             }
490              
491             sub add_plurk {
492 0     0 1 0 my ( $self, @args ) = @_;
493              
494             my ( $content, $lang, $qualifier, $no_comments, @limit )
495             = $self->_msg_common(
496             sub {
497 0     0   0 my $args = shift;
498 0         0 my $no_comments = delete $args->{no_comments};
499 0 0       0 my @limit = @{ delete $args->{limit} || [] };
  0         0  
500 0         0 return ( $no_comments, @limit );
501             },
502             @args
503 0         0 );
504              
505 0         0 my $reply = $self->_json_post(
506             add_plurk => {
507             posted => localtime()->datetime,
508             qualifier => $qualifier,
509             content => $content,
510             lang => $lang,
511             uid => $self->uid,
512             no_comments => ( $no_comments ? 1 : 0 ),
513             @limit
514             ? ( limited_to => '['
515 0 0       0 . join( ',', map { $self->_uid_cast( $_ ) } @limit )
    0          
516             . ']' )
517             : (),
518             }
519             );
520              
521 0 0       0 if ( my $error = $reply->{error} ) {
522 0         0 croak "Error posting: $error";
523             }
524              
525 0         0 return WWW::Plurk::Message->new( $self, $reply->{plurk} );
526             }
527              
528             =head2 C<< plurks >>
529              
530             Get a list of recent Plurks for the logged in user. Returns an array of
531             L objects.
532              
533             my @plurks = $plurk->plurks;
534              
535             Any arguments must be passed as key => value pairs. The following
536             optional arguments are recognised:
537              
538             =over
539              
540             =item * uid - the user whose messages we want
541              
542             =item * date_from - the start date for retrieved messages
543              
544             =item * date_offset - er, not sure what this does :)
545              
546             =back
547              
548             As you may infer from the explanation of C, I'm not
549             entirely sure how this interface works. I cargo-culted the options from
550             the PHP version. If anyone can explain C please let me know
551             and I'll update the documentation.
552              
553             =cut
554              
555             sub plurks {
556 0     0 1 0 my ( $self, @args ) = @_;
557 0 0       0 croak "Needs a number of key => value pairs"
558             if @args & 1;
559 0         0 my %args = @args;
560              
561 0   0     0 my $uid = $self->_uid_cast( delete $args{uid} || $self );
562              
563 0         0 my $date_from = delete $args{date_from};
564 0         0 my $date_offset = delete $args{date_offset};
565              
566 0 0       0 if ( my @extra = sort keys %args ) {
567 0         0 croak "Unknown parameter(s): ", join ',', @extra;
568             }
569              
570 0 0       0 my $reply = $self->_json_post(
    0          
571             get_plurks => {
572             user_id => $uid,
573             defined $date_from
574             ? ( from_date => gmtime( $date_from )->datetime )
575             : (),
576             defined $date_offset
577             ? ( offset => gmtime( $date_offset )->datetime )
578             : (),
579             }
580             );
581              
582             return
583 0 0       0 map { WWW::Plurk::Message->new( $self, $_ ) } @{ $reply || [] };
  0         0  
  0         0  
584             }
585              
586             =head2 C<< unread_plurks >>
587              
588             Return a list of unread Plurks for the current user.
589              
590             =cut
591              
592             sub unread_plurks {
593 0     0 1 0 my $self = shift;
594 0         0 my $reply = $self->_json_post( get_unread_plurks => {} );
595             return
596 0 0       0 map { WWW::Plurk::Message->new( $self, $_ ) } @{ $reply || [] };
  0         0  
  0         0  
597             }
598              
599             # Plurk returns an empty array rather than an empty hash if there
600             # are no elements. D'you think it's written in PHP? :)
601             #
602             # (That's not a dig at PHP, but since arrays and hashes are the same
603             # thing in PHP I assume the JSON encoder can't tell what an empty
604             # hash/array is)
605              
606             sub _want_hash {
607 0     0   0 my ( $self, $hash, @keys ) = @_;
608             # Replace empty arrays with empty hashes at the top level of a hash.
609 0         0 for my $key ( @keys ) {
610 0         0 $hash->{$key} = {}
611             if !exists $hash->{$key}
612             || ( 'ARRAY' eq ref $hash->{$key}
613 0 0 0     0 && @{ $hash->{$key} } == 0 );
      0        
614             }
615             }
616              
617             =head2 C<< responses_for >>
618              
619             Get the responses for a Plurk. Returns a list of
620             L objects. Accepts a single argument which is the
621             numeric ID of the Plurk whose responses we want.
622              
623             my @responses = $plurk->responses_for( $msg->plurk_id );
624              
625             =cut
626              
627             sub responses_for {
628 0     0 1 0 my ( $self, $plurk_id ) = @_;
629              
630 0         0 my $reply
631             = $self->_json_post( get_responses => { plurk_id => $plurk_id } );
632              
633 0         0 $self->_want_hash( $reply, 'friends' );
634              
635 0         0 my %friends = map {
636 0         0 $_ =>
637             WWW::Plurk::Friend->new( $self, $_, $reply->{friends}{$_} )
638 0         0 } keys %{ $reply->{friends} };
639              
640 0         0 return map {
641 0 0       0 WWW::Plurk::Message->new( $self, $_, $friends{ $_->{user_id} } )
642 0         0 } @{ $reply->{responses} || [] };
643             }
644              
645             =head2 C<< respond_to_plurk >>
646              
647             Post a response to an existing Plurk. The first argument must be the ID
648             of the Plurk to respond to. Additional arguments are supplied as a
649             number of key => value pairs. The following arguments are recognised:
650              
651             =over
652              
653             =item * content - the message content
654              
655             =item * qualifier - the qualifier string ('is', 'says' etc)
656              
657             =item * lang - the (human) language for this Plurk
658              
659             =back
660              
661             See C for details of how these arguments are interpreted.
662              
663             my $responce = $plurk->respond_to_plurk(
664             $plurk_id,
665             content => 'Nice!'
666             );
667              
668             Returns an L representing the newly posted
669             response.
670              
671             =cut
672              
673             sub respond_to_plurk {
674 0     0 1 0 my ( $self, $plurk_id, @args ) = @_;
675              
676             my ( $content, $lang, $qualifier )
677 0     0   0 = $self->_msg_common( sub { () }, @args );
  0         0  
678              
679 0         0 my $reply = $self->_json_post(
680             add_response => {
681             posted => localtime()->datetime,
682             qualifier => $qualifier,
683             content => $content,
684             lang => $lang,
685             p_uid => $self->uid,
686             plurk_id => $plurk_id,
687             uid => $self->uid,
688             }
689             );
690              
691 0 0       0 if ( my $error = $reply->{error} ) {
692 0         0 croak "Error posting: $error";
693             }
694              
695 0         0 return WWW::Plurk::Message->new( $self, $reply->{object} );
696             }
697              
698             sub _path_for {
699 11     11   16 my ( $self, $service ) = ( shift, shift );
700 11 50       34 croak "Unknown service $service"
701             unless exists $PATH_DEFAULT{$service};
702 11 50       50 return $self->{path}{$service} unless @_;
703 0         0 return $self->{path}{$service} = shift;
704             }
705              
706             sub _uri_for {
707 11     11   6318 my ( $self, $service ) = ( shift, shift );
708 11         31 my $uri = $self->_base_uri . $self->_path_for( $service );
709 11 50       66 return $uri unless @_;
710 0           my $params = shift;
711 0           return join '?', $uri, HTML::Tiny->new->query_encode( $params );
712             }
713              
714             =head2 Accessors
715              
716             The following accessors are available:
717              
718             =over
719              
720             =item * C<< info >> - the user info hash
721              
722             =item * C<< state >> - the state of this object (init or login)
723              
724             =item * C<< trace >> - set true to enable HTTP query tracing
725              
726             =item * C<< display_name >> - the user's display name
727              
728             =item * C<< full_name >> - the user's full name
729              
730             =item * C<< gender >> - the user's gender
731              
732             =item * C<< has_profile_image >> - has a profile image?
733              
734             =item * C<< id >> - appears to be a synonym for uid
735              
736             =item * C<< is_channel >> - unknown; anyone know?
737              
738             =item * C<< karma >> - user's karma score
739              
740             =item * C<< location >> - user's location
741              
742             =item * C<< nick_name >> - user's nick name
743              
744             =item * C<< page_title >> - unknown; anyone know?
745              
746             =item * C<< relationship >> - married, single, etc
747              
748             =item * C<< star_reward >> - ???
749              
750             =item * C<< uid >> - the user's ID
751              
752             =back
753              
754             =cut
755              
756             1;
757             __END__