1package WWW::Plurk; 2 3use warnings; 4use strict; 5 6use Carp; 7use DateTime::Format::Mail; 8use HTML::Tiny; 9use HTTP::Cookies; 10use JSON; 11use Data::Dumper; 12use LWP::UserAgent; 13use Time::Piece; 14use WWW::Plurk::Friend; 15use WWW::Plurk::Message; 16 17=head1 NAME 18 19WWW::Plurk - Unoffical plurk.com API 20 21=head1 VERSION 22 23This document describes WWW::Plurk version 0.02 24 25=cut 26 27our $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 38This is an unofficial API for plurk.com. It uses the same interfaces 39that plurk itself uses internally which are not published and not 40necessarily stable. When Plurk publish a stable API this module will be 41updated to take advantage of it. In the mean time use with caution. 42 43Ryan Lim did the heavy lifting of reverse engineering the API. His PHP 44implementation can be found at L<http://code.google.com/p/rlplurkapi/>. 45 46If you'd like to lend a hand supporting the bits of Plurk that this API 47doesn't yet reach please feel free to send me a patch. The Plurk API 48Wiki at L<http://plurkwiki.badchemicals.net/> is a good source of 49information. 50 51=cut 52 53# Default API URIs 54 55use constant MAX_MESSAGE_LENGTH => 140; 56 57my $BASE_DEFAULT = 'http://www.plurk.com'; 58 59my %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 74BEGIN { 75 my @ATTR = qw( 76 _base_uri 77 info 78 state 79 trace 80 ); 81 82 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 for my $attr ( @ATTR ) { 99 no strict 'refs'; 100 *{$attr} = sub { 101 my $self = shift; 102 return $self->{$attr} unless @_; 103 return $self->{$attr} = shift; 104 }; 105 } 106 107 for my $info ( @INFO ) { 108 no strict 'refs'; 109 *{$info} = sub { 110 my $self = shift; 111 # Info attributes only available when logged in 112 $self->_logged_in; 113 return $self->info->{$info}; 114 }; 115 } 116} 117 118=head1 INTERFACE 119 120All methods throw errors in the event of any kind of failure. There's no 121need to check return values but you might want to wrap calls in an 122C<eval> block. 123 124=head2 C<< new >> 125 126Create a new C<< WWW::Plurk >>. Optionally accepts two arguments 127(username, password). If they are supplied it will attempt to login to 128Plurk. If no arguments are supplied C<login> must be called before 129attempting 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 140sub new { 141 my $class = shift; 142 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 if ( @_ ) { 150 croak "Need two arguments (user, pass) if any are supplied" 151 unless @_ == 2; 152 $self->login( @_ ); 153 } 154 155 return $self; 156} 157 158sub _make_ua { 159 my $self = shift; 160 my $ua = LWP::UserAgent->new; 161 $ua->agent( join ' ', __PACKAGE__, $VERSION ); 162 $ua->cookie_jar( HTTP::Cookies->new ); 163 return $ua; 164} 165 166sub _ua { 167 my $self = shift; 168 return $self->{_ua} ||= $self->_make_ua; 169} 170 171sub _trace { 172 my ( $self, @msgs ) = @_; 173 if ( $self->trace ) { 174 print STDERR "$_\n" for @msgs; 175 } 176} 177 178sub _raw_post { 179 my ( $self, $uri, $params ) = @_; 180 $self->_trace( 181 POST => $uri, 182 Data::Dumper->Dump( [$params], [qw($params)] ) 183 ); 184 my $resp = $self->_ua->post( $uri, $params ); 185 $self->_trace( $resp->status_line ); 186 return $resp; 187} 188 189sub _raw_get { 190 my ( $self, $uri ) = @_; 191 $self->_trace( GET => $uri ); 192 my $resp = $self->_ua->get( $uri ); 193 $self->_trace( $resp->status_line ); 194 return $resp; 195} 196 197sub _cookies { shift->_ua->cookie_jar } 198 199sub _post { 200 my ( $self, $service, $params ) = @_; 201 my $resp 202 = $self->_raw_post( $self->_uri_for( $service ), $params || {} ); 203 croak $resp->status_line 204 unless $resp->is_success 205 or $resp->is_redirect; 206 return $resp; 207} 208 209sub _json_post { 210 my $self = shift; 211 return $self->_decode_json( $self->_post( @_ )->content ); 212} 213 214sub _get { 215 my ( $self, $service, $params ) = @_; 216 my $resp 217 = $self->_raw_get( $self->_uri_for( $service, $params || {} ) ); 218 croak $resp->status_line 219 unless $resp->is_success 220 or $resp->is_redirect; 221 return $resp; 222} 223 224sub _json_get { 225 my $self = shift; 226 return $self->_decode_json( $self->_get( @_ )->content ); 227} 228 229=head2 C<< login >> 230 231Attempt to login to a Plurk account. The two mandatory arguments are the 232username and password for the account to be accessed. 233 234 my $plurk = WWW::Plurk->new; 235 $plurk->login( 'user', 'pass' ); 236 237=cut 238 239sub login { 240 my ( $self, $name, $pass ) = @_; 241 242 my $resp = $self->_post( 243 login => { 244 nick_name => $name, 245 password => $pass, 246 } 247 ); 248 249 my $ok = 0; 250 $self->_cookies->scan( sub { $ok++ if $_[1] eq 'plurkcookiea' } ); 251 croak "Login for $name failed, no cookie returned" 252 unless $ok; 253 254 $self->_path_for( home => $resp->header( 'Location' ) 255 || "/user/$name" ); 256 257 $self->_parse_user_home; 258 $self->state( 'login' ); 259} 260 261sub _parse_time { 262 my ( $self, $time ) = @_; 263 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 286sub _decode_json { 287 my ( $self, $json ) = @_; 288 289 my %strings = (); 290 my $next_token = 1; 291 292 my $tok = sub { 293 my $str = shift; 294 my $key = sprintf '#%d#', $next_token++; 295 $strings{$key} = $str; 296 return qq{"$key"}; 297 }; 298 299 # Stash string literals to avoid false positives 300 $json =~ s{ " ( (?: \\. | [^\\"]+ )* ) " }{ $tok->( $1 ) }xeg; 301 302 # Plurk actually returns JS rather than JSON. 303 $json =~ s{ new \s+ Date \s* \( \s* " (\#\d+\#) " \s* \) } 304 { $self->_parse_time( $strings{$1} ) }xeg; 305 306 # Replace string literals 307 $json =~ s{ " (\#\d+\#) " }{ qq{"$strings{$1}"} }xeg; 308 309 # Now we have JSON 310 return decode_json $json; 311} 312 313sub _parse_user_home { 314 my $self = shift; 315 my $resp = $self->_get( 'home' ); 316 if ( $resp->content =~ /^\s*var\s+GLOBAL\s*=\s*(.+)$/m ) { 317 my $global = $self->_decode_json( $1 ); 318 $self->info( 319 $global->{session_user} 320 or croak "No session_user data found" 321 ); 322 } 323 else { 324 croak "Can't find GLOBAL data on user page"; 325 } 326} 327 328=head2 C<< is_logged_in >> 329 330Returns 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 338sub is_logged_in { shift->state eq 'login' } 339 340sub _logged_in { 341 my $self = shift; 342 croak "Please login first" 343 unless $self->is_logged_in; 344} 345 346=head2 C<< friends_for >> 347 348Return a user's friends. 349 350 my @friends = $plurk->friends_for( $uid ); 351 352Pass 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<uid> 361 362 # $some_user isa WWW::Plurk::Friend 363 my @friends = $plurk->friends_for( $some_user ); 364 365=back 366 367Returns a list of L<WWW::Plurk::Friend> objects. 368 369=cut 370 371sub friends_for { 372 my $self = shift; 373 my $for = $self->_uid_cast( shift || $self ); 374 $self->_logged_in; 375 my $friends 376 = $self->_json_get( get_completion => { user_id => $for } ); 377 return map { WWW::Plurk::Friend->new( $self, $_, $friends->{$_} ) } 378 keys %$friends; 379} 380 381=head2 C<< friends >> 382 383Return the current user's friends. This 384 385 my @friends = $plurk->friends; 386 387is equivalent to 388 389 my @friends = $plurk->friends_for( $self->uid ); 390 391=cut 392 393sub friends { 394 my $self = shift; 395 return $self->friends_for( $self ); 396} 397 398=head2 C<< add_plurk >> 399 400Post a new plurk. 401 402 $plurk->add_plurk( 403 content => 'Hello, World' 404 ); 405 406Arguments are supplied as a number of key, value pairs. The following 407arguments 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 423The only mandatory argument is C<content> which should be a string of 424140 characters or fewer. 425 426C<qualifier> is first word of the message - which has special 427significance that you will understand if you have looked at the Plurk 428web 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 433If omitted C<qualifier> defaults to ':' which signifies that you are 434posting a free-form message with no qualifier. 435 436C<lang> is the human language for this Plurk. It defaults to 'en'. 437Apologies to those posting in languages other than English. 438 439C<no_comments> should be true to lock the Plurk preventing comments from 440being made. 441 442C<limited_to> is an array of user ids (or objects with a method called 443C<uid>). If present the Plurk will only be visible to those users. To 444limit 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 451Returns a L<WWW::Plurk::Message> representing the new Plurk. 452 453=cut 454 455sub _is_user { 456 my ( $self, $obj ) = @_; 457 return UNIVERSAL::can( $obj, 'can' ) && $obj->can( 'uid' ); 458} 459 460sub _uid_cast { 461 my ( $self, $obj ) = @_; 462 return $self->_is_user( $obj ) ? $obj->uid : $obj; 463} 464 465sub _msg_common { 466 my ( $self, $cb, @args ) = @_; 467 468 croak "Needs a number of key => value pairs" 469 if @args & 1; 470 my %args = @args; 471 472 my $content = delete $args{content} || croak "Must have content"; 473 my $lang = delete $args{lang} || 'en'; 474 my $qualifier = delete $args{qualifier} || ':'; 475 476 my @extras = $cb->( \%args ); 477 478 if ( my @unknown = sort keys %args ) { 479 croak "Unknown parameter(s): ", join ',', @unknown; 480 } 481 482 if ( length $content > MAX_MESSAGE_LENGTH ) { 483 croak 'Plurks are limited to ' 484 . MAX_MESSAGE_LENGTH 485 . ' characters'; 486 } 487 488 return ( $content, $lang, $qualifier, @extras ); 489} 490 491sub add_plurk { 492 my ( $self, @args ) = @_; 493 494 my ( $content, $lang, $qualifier, $no_comments, @limit ) 495 = $self->_msg_common( 496 sub { 497 my $args = shift; 498 my $no_comments = delete $args->{no_comments}; 499 my @limit = @{ delete $args->{limit} || [] }; 500 return ( $no_comments, @limit ); 501 }, 502 @args 503 ); 504 505 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 . join( ',', map { $self->_uid_cast( $_ ) } @limit ) 516 . ']' ) 517 : (), 518 } 519 ); 520 521 if ( my $error = $reply->{error} ) { 522 croak "Error posting: $error"; 523 } 524 525 return WWW::Plurk::Message->new( $self, $reply->{plurk} ); 526} 527 528=head2 C<< plurks >> 529 530Get a list of recent Plurks for the logged in user. Returns an array of 531L<WWW::Plurk::Message> objects. 532 533 my @plurks = $plurk->plurks; 534 535Any arguments must be passed as key => value pairs. The following 536optional 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 548As you may infer from the explanation of C<date_offset>, I'm not 549entirely sure how this interface works. I cargo-culted the options from 550the PHP version. If anyone can explain C<date_offset> please let me know 551and I'll update the documentation. 552 553=cut 554 555sub plurks { 556 my ( $self, @args ) = @_; 557 croak "Needs a number of key => value pairs" 558 if @args & 1; 559 my %args = @args; 560 561 my $uid = $self->_uid_cast( delete $args{uid} || $self ); 562 563 my $date_from = delete $args{date_from}; 564 my $date_offset = delete $args{date_offset}; 565 566 if ( my @extra = sort keys %args ) { 567 croak "Unknown parameter(s): ", join ',', @extra; 568 } 569 570 my $reply = $self->_json_post( 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 map { WWW::Plurk::Message->new( $self, $_ ) } @{ $reply || [] }; 584} 585 586=head2 C<< unread_plurks >> 587 588Return a list of unread Plurks for the current user. 589 590=cut 591 592sub unread_plurks { 593 my $self = shift; 594 my $reply = $self->_json_post( get_unread_plurks => {} ); 595 return 596 map { WWW::Plurk::Message->new( $self, $_ ) } @{ $reply || [] }; 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 606sub _want_hash { 607 my ( $self, $hash, @keys ) = @_; 608 # Replace empty arrays with empty hashes at the top level of a hash. 609 for my $key ( @keys ) { 610 $hash->{$key} = {} 611 if !exists $hash->{$key} 612 || ( 'ARRAY' eq ref $hash->{$key} 613 && @{ $hash->{$key} } == 0 ); 614 } 615} 616 617=head2 C<< responses_for >> 618 619Get the responses for a Plurk. Returns a list of 620L<WWW::Plurk::Message> objects. Accepts a single argument which is the 621numeric ID of the Plurk whose responses we want. 622 623 my @responses = $plurk->responses_for( $msg->plurk_id ); 624 625=cut 626 627sub responses_for { 628 my ( $self, $plurk_id ) = @_; 629 630 my $reply 631 = $self->_json_post( get_responses => { plurk_id => $plurk_id } ); 632 633 $self->_want_hash( $reply, 'friends' ); 634 635 my %friends = map { 636 $_ => 637 WWW::Plurk::Friend->new( $self, $_, $reply->{friends}{$_} ) 638 } keys %{ $reply->{friends} }; 639 640 return map { 641 WWW::Plurk::Message->new( $self, $_, $friends{ $_->{user_id} } ) 642 } @{ $reply->{responses} || [] }; 643} 644 645=head2 C<< respond_to_plurk >> 646 647Post a response to an existing Plurk. The first argument must be the ID 648of the Plurk to respond to. Additional arguments are supplied as a 649number 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 661See C<add_plurk> for details of how these arguments are interpreted. 662 663 my $responce = $plurk->respond_to_plurk( 664 $plurk_id, 665 content => 'Nice!' 666 ); 667 668Returns an L<WWW::Plurk::Message> representing the newly posted 669response. 670 671=cut 672 673sub respond_to_plurk { 674 my ( $self, $plurk_id, @args ) = @_; 675 676 my ( $content, $lang, $qualifier ) 677 = $self->_msg_common( sub { () }, @args ); 678 679 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 if ( my $error = $reply->{error} ) { 692 croak "Error posting: $error"; 693 } 694 695 return WWW::Plurk::Message->new( $self, $reply->{object} ); 696} 697 698sub _path_for { 699 my ( $self, $service ) = ( shift, shift ); 700 croak "Unknown service $service" 701 unless exists $PATH_DEFAULT{$service}; 702 return $self->{path}{$service} unless @_; 703 return $self->{path}{$service} = shift; 704} 705 706sub _uri_for { 707 my ( $self, $service ) = ( shift, shift ); 708 my $uri = $self->_path_for( $service ); 709 $uri = $self->_base_uri . $uri if $uri !~ m{^http}; 710 return $uri unless @_; 711 my $params = shift; 712 return join '?', $uri, HTML::Tiny->new->query_encode( $params ); 713} 714 715=head2 Accessors 716 717The following accessors are available: 718 719=over 720 721=item * C<< info >> - the user info hash 722 723=item * C<< state >> - the state of this object (init or login) 724 725=item * C<< trace >> - set true to enable HTTP query tracing 726 727=item * C<< display_name >> - the user's display name 728 729=item * C<< full_name >> - the user's full name 730 731=item * C<< gender >> - the user's gender 732 733=item * C<< has_profile_image >> - has a profile image? 734 735=item * C<< id >> - appears to be a synonym for uid 736 737=item * C<< is_channel >> - unknown; anyone know? 738 739=item * C<< karma >> - user's karma score 740 741=item * C<< location >> - user's location 742 743=item * C<< nick_name >> - user's nick name 744 745=item * C<< page_title >> - unknown; anyone know? 746 747=item * C<< relationship >> - married, single, etc 748 749=item * C<< star_reward >> - ??? 750 751=item * C<< uid >> - the user's ID 752 753=back 754 755=cut 756 7571; 758__END__ 759 760=head1 CONFIGURATION AND ENVIRONMENT 761 762WWW::Plurk requires no configuration files or environment variables. 763 764=head1 DEPENDENCIES 765 766None. 767 768=head1 INCOMPATIBILITIES 769 770None reported. 771 772=head1 BUGS AND LIMITATIONS 773 774No bugs have been reported. 775 776Please report any bugs or feature requests to 777C<bug-www-plurk@rt.cpan.org>, or through the web interface at 778L<http://rt.cpan.org>. 779 780=head1 AUTHOR 781 782Andy Armstrong C<< <andy.armstrong@messagesystems.com> >> 783 784L<< http://www.plurk.com/user/AndyArmstrong >> 785 786=head1 LICENCE AND COPYRIGHT 787 788This module is free software; you can redistribute it and/or 789modify it under the same terms as Perl itself. See L<perlartistic>. 790 791Copyright (c) 2008, Message Systems, Inc. 792All rights reserved. 793 794Redistribution and use in source and binary forms, with or 795without modification, are permitted provided that the following 796conditions are met: 797 798 * Redistributions of source code must retain the above copyright 799 notice, this list of conditions and the following disclaimer. 800 * Redistributions in binary form must reproduce the above copyright 801 notice, this list of conditions and the following disclaimer in 802 the documentation and/or other materials provided with the 803 distribution. 804 * Neither the name Message Systems, Inc. nor the names of its 805 contributors may be used to endorse or promote products derived 806 from this software without specific prior written permission. 807 808THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 809IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 810TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 811PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER 812OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 813EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 814PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 815PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 816LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 817NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 818SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 819