#!/usr/bin/env perl use strict; use warnings; use Carp; use Pod::Usage qw( pod2usage ); use Getopt::Long qw( :config gnu_getopt ); use version; my $VERSION = qv('0.3.0'); use English qw( -no_match_vars ); use WWW::Mechanize; use IO::Prompt qw( prompt ); use File::Slurp qw( slurp ); use Config::Tiny; use Path::Class qw( file ); use List::MoreUtils qw( uniq ); # Integrated logging facility use Log::Log4perl qw( :easy :no_extra_logdie_message ); Log::Log4perl->easy_init($INFO); my %defaults = ( config => file($ENV{HOME}, '.zooomr')->stringify(), login_page => 'http://www.zooomr.com/login/', upload_page => 'http://www.zooomr.com/photos/upload/?noflash=okiwill', logout_page => 'http://www.zooomr.com/logout/', search_page => 'http://www.zooomr.com/search/photos/', login => 1, # by default, try to login pause => 5, backoff => 5, max_retry => 4, debug => $INFO, ); # Script implementation here my %config = get_configuration(%defaults); if ($config{check}) { print {*STDERR} "configuration OK\n"; exit 0; } get_logger()->level( { TRACE => $TRACE, DEBUG => $DEBUG, INFO => $INFO, WARN => $WARN, ERROR => $ERROR, FATAL => $FATAL, }->{uc $config{debug}} || $INFO ); INFO 'configuration OK'; # On with the show my $ua = WWW::Mechanize->new(autocheck => 1); eval { my $action = __PACKAGE__->can($config{action}) or die "You've found a bug! Someway, $config{action} is not supported\n"; $ua->env_proxy(); $ua->proxy('http', $config{proxy}) if exists $config{proxy}; if ($config{login}) { if ($config{cookie}) { INFO "getting login info from cookie file '$config{cookie}'"; require HTTP::Cookies::Netscape; my $jar = HTTP::Cookies::Netscape->new(autosave => 0); $jar->load($config{cookie}) or die "could not load cookie file $config{cookie}\n"; $ua->cookie_jar($jar); } else { INFO "logging into account $config{username}"; login(); } } $action->(); if ($config{login} && !$config{cookie}) { INFO "logging out"; logout(); } }; ERROR $EVAL_ERROR if $EVAL_ERROR; INFO 'all operations completed'; sub get_configuration { my %config = @_; my $action = shift @ARGV; $action = '--usage' unless defined $action; # First of all, try to honor meta-options pod2usage(message => "$0 $VERSION", -verbose => 99, -sections => '') if $action eq '--version'; pod2usage(-verbose => 99, -sections => 'USAGE') if $action eq '--usage'; pod2usage(-verbose => 99, -sections => 'USAGE|EXAMPLES|OPTIONS') if $action eq '--help'; pod2usage(-verbose => 2) if $action eq '--man'; # getting here means that it's a real call for something my @common_options = qw( username|user|u=s password|pass|p=s proxy|P=s cookie|cookie-file|k=s config|C=s check|dry-run|c! dump|d! summary|summary-file|s=s tag|t=s@ debug|D=s ); my %options_for = ( add => [ qw( files|files-from|f=s@ resize=i public! private! family! friends! backoff=i pause=i max_retry|max-retry|m=i ) ], search => [qw( login! maxpage|max-page|m=i who|w=s )], ); pod2usage( message => "error: operation '$action' not supported\n", -verbose => 99, -sections => 'USAGE' ) unless exists $options_for{$action}; # This will record the original parameters from different sources my %sources = (_script => {%config}); my %cmdline; GetOptions(\%cmdline, @common_options, @{$options_for{$action}}); %config = (%config, %cmdline); # First merge, cmdline overrides $sources{_cmdline} = \%cmdline; # Read defaults from configuration file, if exists if (-e $config{config}) { my $cfile = Config::Tiny->read($config{config}); %config = (%config, %{$cfile->{_}}, %cmdline); $sources{_cfile} = $cfile; } ## end if (-e $config{config}) # Record original options for validators to make changes and action %config = (%config, %sources, action => $action, argv => [@ARGV]); @ARGV = (); # empty, already recorded in config # Call relevant method for parameter checking, if exists if (my $sub = __PACKAGE__->can('validate_' . $action)) { $sub->(\%config); } if ($config{dump}) { require Data::Dumper; local $Data::Dumper::Indent; local $Data::Dumper::Indent = 1; print {*STDOUT} Data::Dumper->Dump([\%config], ['configuration']); } ## end if ($config{dump}) # Now check/adjust common parameters push @{$config{tag}}, $config{tags} if defined $config{tags}; $config{tags} = join ' ', @{$config{tag} || []}; # Check username existence and prompt for password if necessary # If cookie file has been set, don't ask for a username, will check # later if it's all ok if ($config{login}) { if ($config{cookie}) { pod2usage( message => "error: please provide an existent cookie file\n", -verbose => 99, -sections => 'USAGE', ) unless -r $config{cookie} && -f $config{cookie}; } else { pod2usage( message => "error: please provide a username or a cookie file\n", -verbose => 99, -sections => 'USAGE' ) unless exists $config{username}; $config{password} = prompt 'password: ', -echo => '*' unless exists $config{password}; } } ## end if ($config{login} && ... return %config; } ## end sub get_configuration sub login { $ua->get($config{login_page}); $ua->form_with_fields(qw( username password )); $ua->set_fields( username => $config{username}, password => $config{password}, ); $ua->submit(); return; } ## end sub login sub logout { $ua->get($config{logout_page}); return; } sub validate_add { my $config = shift; my %config = %$config; # Establish real privacy, command line overrides configuration @config{qw( private family friends )} = () if $config{_cmdline}{public}; $config{private} ||= $config{family} || $config{friends}; $config{public} = $config{private} ? 0 : 1; delete $config{private}; # Ensure there's some files to work on my @cmdline_files = @{$config{argv}}; my @filed_files = map { chomp(my @lines = slurp($_)); @lines; } @{$config{files} || []}; my @filenames = uniq grep { if (-e $_) { 1 } else { print {*STDERR} "file '$_' does not exist, ignoring\n"; 0; } } @cmdline_files, @filed_files; pod2usage( message => "error: no file to upload\n", -verbose => 99, -sections => 'USAGE' ) unless @filenames; $config{filenames} = \@filenames; %$config = %config; return; } ## end sub validate_add sub add { INFO "starting file upload"; my $retry = 0; my $backoff = $config{backoff}; my @results; my @failed; FILE: for my $filename (@{$config{filenames}}) { (my $barename = file($filename)->basename()) =~ s{\.\w+\z}{}mxs; eval { $ua->get($config{upload_page}) or die "couldn't get '$config{upload_page}'\n"; $ua->form_with_fields(qw( Filedata labels )); $ua->set_fields( labels => $config{tags}, is_public => $config{public}, Filedata => $filename, ); $ua->tick('is_friend', 1, $config{friends}); $ua->tick('is_family', 1, $config{family}); if ($config{resize} && (my $resized = add_resize($filename))) { my $input = $ua->current_form()->find_input('Filedata'); $input->filename($filename); # just to be on the safe side $input->content($resized); } INFO "uploading '$filename'"; $ua->submit(); }; my $error = 0; # assume no error just to begin if ($EVAL_ERROR) { ERROR $EVAL_ERROR; $error = 1; } else { # Now check that the photo is actually there... if (my $link = $ua->find_link(text => $barename)) { INFO 'upload successful'; push @results, [ $filename, $link->url_abs() ]; } else { ERROR 'no error received, but the photo is not there'; $error = 1; } } ## end else [ if ($EVAL_ERROR) if ($error) { # Retry scheme if (++$retry <= $config{max_retry}) { if ($backoff) { INFO "sleeping $backoff second" . ($backoff == 1 ? '' : 's'); sleep $backoff if $backoff; $backoff *= 2; # exponential backoff } ## end if ($backoff) redo FILE; } ## end if (++$retry <= $config... ERROR "giving up on '$filename'"; push @results, [ $filename, '**FAILED**' ]; push @failed, $filename; } ## end if ($error) # Reset these values for next photo upload $retry = 0; $backoff = $config{backoff}; # Sleep a bit if configured. Avoid sleeping after last photo sleep $config{pause} if $config{pause} and $filename ne $config{filenames}[-1]; } ## end for my $filename (@{$config... # Recap on failed files ERROR 'failed files: ', join ' | ', @failed if @failed; # Summary, if requested if (defined $config{summary}) { if (open my $fh, '>', $config{summary}) { print {$fh} "File\tURI\n"; print {$fh} join("\t", @$_), "\n" for @results; close $fh; } else { ERROR "could not open $config{summary}: $OS_ERROR"; } } return; } ## end sub add sub add_resize { my ($filename) = @_; require Image::Magick; require File::Temp; my $magick = Image::Magick->new(); my ($width, $height, $size, $format) = $magick->Ping($filename); return if $width <= $config{resize} && $height <= $config{resize}; my ($neww, $newh); if ($width > $height) { $neww = $config{resize}; $newh = int($height * $neww / $width); } else { $newh = $config{resize}; $neww = int($width * $newh / $height); } my $ecode; $ecode = $magick->Read($filename) and die $ecode; $ecode = $magick->Resize(width => $neww, height => $newh) and die $ecode; my $scaled; my $fh = File::Temp::tempfile(); binmode $fh; $magick->Write(file => $fh, filename => $filename); seek $fh, 0, 0; # rewind $scaled = slurp $fh; close $fh; return $scaled; } ## end sub add_resize sub validate_search { my $conf = shift; my %entry_for = ( all => 1, social => 3, contacts => 3, everyone => 1, ); $conf->{who} = $entry_for{$conf->{who} || ''}; $conf->{who} ||= 2; # default to 'me' $conf->{who} = 1 unless $conf->{login}; return; } ## end sub validate_search sub search { INFO "starting search"; $ua->get($config{search_page}); $ua->form_with_fields(qw( w q )); $ua->select('w', {n => $config{who}}); $ua->field('q', $config{tags}); $ua->click(); my ($matches, $word) = $ua->content() =~ /we found ([\d,]+) (photos?)/msi; if (!defined $matches) { INFO "no photo matching the criteria"; return; } my $npages = 1; if (my @pages = $ua->find_all_links(url_regex => qr/\&page=(\d+)\z/mxs)) { ($npages) = $pages[-2]->url() =~ /(\d+)\z/msx; } my $npages_w = $npages == 1 ? 'page' : 'pages'; INFO "found $matches $word in $npages $npages_w"; my $base_url = $ua->uri(); $npages = $config{maxpage} if defined $config{maxpage} && $npages > $config{maxpage}; for my $page_id (1 .. $npages) { $ua->get($base_url . "&page=$page_id"); my @photos = $ua->find_all_links(url_regex => qr{/photos/.*/\d+/},); print join("\n* ", "Page $page_id:", map { $_->url() } @photos), "\n"; } ## end for my $page_id (1 .. $npages) return; } ## end sub search __END__ =head1 NAME zooomr - a simple command-line interface for Zooomr =head1 VERSION shell$ zooomr --version =head1 USAGE zooomr [--usage] [--help] [--man] [--version] zooomr [] [--check|-c] [--config|-C ] [--cookie|--cookie-file|-k ] [--debug|-D] [--dump|-d] [--login] [--password|-p password] [--proxy | -P ] [--summary|--summary-file|-s ] [--tag|-t tag1 [--tag|-t tag2 [...]]] [--username|-u ] command: add [--backoff