File Coverage

blib/lib/Comics.pm
Criterion Covered Total %
statement 58 288 20.1
branch 1 116 0.8
condition 0 35 0.0
subroutine 20 43 46.5
pod n/a
total 79 482 16.3


line stmt bran cond sub pod time code
1             #! perl
2              
3             # Author : Johan Vromans
4             # Created On : Fri Oct 21 09:18:23 2016
5             # Last Modified By:
6             # Last Modified On: Mon Feb 26 09:40:52 2024
7             # Update Count : 393
8             # Status : Unknown, Use with caution!
9              
10 1     1   130715 use 5.012;
  1         4  
11 1     1   20 use strict;
  1         3  
  1         43  
12 1     1   5 use warnings;
  1         3  
  1         56  
13 1     1   591 use utf8;
  1         309  
  1         7  
14 1     1   43 use Carp;
  1         2  
  1         72  
15              
16             package Comics;
17              
18 1     1   488 use Comics::Version;
  1         4  
  1         57  
19              
20             our $VERSION = $Comics::Version::VERSION;
21              
22             package main;
23              
24             ################ Common stuff ################
25              
26 1     1   6 use strict;
  1         2  
  1         21  
27 1     1   3 use warnings;
  1         5  
  1         70  
28 1     1   2461 use FindBin;
  1         3272  
  1         378  
29 1     1   9 use File::Spec;
  1         2  
  1         161  
30 1     1   9 use File::Path qw();
  1         2  
  1         80  
31              
32             BEGIN {
33             # Add private library if it exists.
34 1 50   1   104 if ( -d "$FindBin::Bin/../lib" ) {
35 1         71 unshift( @INC, "$FindBin::Bin/../lib" );
36             }
37             }
38              
39             # Package name.
40             my $my_package = 'Sciurix';
41             # Program name.
42             my $my_name = "comics";
43              
44             ################ Command line parameters ################
45              
46 1     1   1885 use Getopt::Long 2.13;
  1         33863  
  1         98  
47              
48             # Command line options.
49             my $spooldir = File::Spec->catdir( File::Spec->tmpdir, "Comics" );
50             my $statefile;
51             my $refresh;
52             my $activate = 0; # enable/disable
53             my $force; # process disabled modules as well
54             my $rebuild; # rebuild index, no fetching
55             my $list; # produce listing
56             my $verbose = 1; # verbose processing
57             my $reuse = 0; # reuse existing fetch results
58              
59             # Development options (not shown with -help).
60             my $debug = 0; # debugging
61             my $trace = 0; # trace (show process)
62             my $test = 0; # test mode.
63              
64             # Extra command line arguments are taken to be plugin names.
65             # If specified, only named plugins are included.
66             my $pluginfilter;
67              
68             ################ Presets ################
69              
70             ################ The Process ################
71              
72 1     1   1402 use File::LoadLines;
  1         43710  
  1         836  
73              
74             # Statistics.
75             our $stats;
76              
77             sub init {
78 0     0     $stats =
79             { tally => 0,
80             fail => [],
81             loaded => 0,
82             uptodate => 0,
83             excluded => 0,
84             disabled => 0,
85             };
86              
87             # Process command line options.
88 0           app_options();
89              
90             # Post-processing.
91 0   0       $trace |= ($debug || $test);
92 0 0         $verbose = 255 if $debug;
93 0           $spooldir .= "/";
94 0           $spooldir =~ s;/+$;/;;
95              
96 0 0         File::Path::make_path( $spooldir, { verbose => 1 } )
97             unless -d $spooldir;
98              
99 0           $statefile = spoolfile(".state.json");
100              
101 0           $pluginfilter = ".";
102 0 0         if ( @ARGV ) {
103 0           $pluginfilter = "^(?:" . join("|", @ARGV) . ")\\.pm\$";
104             }
105 0           $pluginfilter = qr($pluginfilter)i;
106              
107             }
108              
109             sub main {
110              
111             # Initialize.
112 0     0     init();
113              
114             # Restore state of previous run.
115 0           get_state();
116              
117             # Load the plugins.
118 0           load_plugins();
119              
120             # Non-aggregating command: list.
121 0 0         if ( $list ) {
122 0           list_plugins();
123 0           return;
124             }
125              
126             # Non-aggregating command: enable/disable.
127 0 0         if ( $activate ) {
128 0           save_state();
129 0 0         return unless $rebuild;
130             }
131              
132 0 0         unless ( $rebuild ) {
133             # Run the plugins to fetch new images.
134 0           run_plugins();
135              
136             # Save the state.
137 0           save_state();
138             }
139              
140             # Gather the HTML fragments into a single index.html.
141 0           build();
142              
143             # Show processing statistics.
144 0           statistics();
145             }
146              
147             ################ State subroutines ################
148              
149 1     1   1822 use JSON;
  1         20853  
  1         9  
150              
151             my $state;
152              
153             sub get_state {
154 0     0     my $opts = { split => 0, fail => "soft" };
155 0           my $data = loadlines( $statefile, $opts );
156 0 0         if ( $opts->{error} ) {
157 0           $state = { comics => { } };
158             }
159             else {
160 0           $state = JSON->new->decode($data);
161 0 0         if ( $refresh ) {
162             delete( $_->{md5} )
163 0           foreach values( %{ $state->{comics} } );
  0            
164             }
165             }
166             }
167              
168             sub save_state {
169 0     0     unlink($statefile."~");
170 0           rename( $statefile, $statefile."~" );
171 0           open( my $fd, '>:utf8', $statefile );
172 0           print $fd JSON->new->canonical->pretty(1)->encode($state);
173 0           close($fd);
174             }
175              
176             ################ Plugin subroutines ################
177              
178             my @plugins;
179              
180             sub load_plugins {
181              
182 0 0   0     opendir( my $dh, $INC[0] . "/Comics/Plugin" )
183             or die( $INC[0] . "/Comics/Plugin: $!\n");
184              
185 0           while ( my $m = readdir($dh) ) {
186 0 0         next unless $m =~ /^[0-9A-Z].*\.pm$/;
187 0 0         next if $m eq 'Base.pm';
188 0           $stats->{loaded}++;
189 0 0         $stats->{excluded}++, next unless $m =~ $pluginfilter;
190              
191 0           debug("Loading $m...");
192 0           $m =~ s/\.pm$//;
193             # If the module is already loaded, remove it first.
194             # Otherwise the require won't produce the __PACKAGE__ result.
195 0           delete $INC{"Comics/Plugin/$m.pm"};
196 0           my $pkg = eval { require "Comics/Plugin/$m.pm" };
  0            
197 0 0         die("Comics/Plugin/$m.pm: $@\n") unless $pkg;
198 0 0         unless ( $pkg eq "Comics::Plugin::$m" ) {
199 0           warn("Skipped $m.pm (defines $pkg, should be Comics::Plugin::$m)\n");
200 0           next;
201             }
202 0           my $comic = $pkg->register;
203 0 0         next unless $comic;
204              
205 0           push( @plugins, $comic );
206 0           my $tag = $comic->{tag};
207              
208             # 'disabled' means that this plugin is permanently disabled.
209 0 0         my $activate = $comic->{disabled} ? -1 : $activate;
210              
211             # 'ondemand' means that this plugin is initially disabled, but
212             # can be enabled if desired.
213 0 0 0       if ( !$activate && $comic->{ondemand}
      0        
214             && !exists( $state->{comics}->{$tag} ) ) {
215 0           $activate = -1;
216             }
217              
218 0 0         if ( $activate > 0 ) {
    0          
219             delete( $state->{comics}->{$tag}->{disabled} )
220 0           }
221             elsif ( $activate < 0 ) {
222 0           $state->{comics}->{$tag}->{disabled} = 1;
223 0           delete( $state->{comics}->{$tag}->{md5} );
224 0           for ( qw( html jpg png gif ) ) {
225 0 0         next unless unlink( spoolfile( $tag . "." . $_ ) );
226 0           debug( "Removed: ", spoolfile( $tag . "." . $_ ) );
227 0           $rebuild++;
228             }
229 0           for ( $state->{comics}->{$tag}->{c_img} ) {
230 0 0         next unless defined;
231 0 0         next unless unlink( spoolfile($_) );
232 0           debug( "Removed: ", spoolfile($_) );
233 0           $rebuild++;
234             }
235             }
236              
237 0 0         if ( $state->{comics}->{$tag}->{disabled} ) {
238 0           $stats->{disabled}++;
239 0           debug("Comics::Plugin::$m: Disabled");
240             }
241              
242             }
243              
244 0 0         if ( $stats->{loaded} == $stats->{excluded} ) {
245 0           warn( "No matching plugins found\n" );
246             }
247             }
248              
249             sub list_plugins {
250              
251 0     0     my $lpl = length("Comics::Plugin::");
252 0           my $lft = length("Comics::Fetcher::");
253 0           my ( $l_name, $l_plugin, $l_fetcher ) = ( 0, 0, $lft+8 );
254              
255 0           my @tm;
256             @plugins =
257             sort { ($state->{comics}->{$a->{tag}}->{disabled} // 0) <=>
258             ($state->{comics}->{$b->{tag}}->{disabled} // 0) ||
259             $b->{update} <=> $a->{update} ||
260             $a->{name} cmp $b->{name}
261 0 0 0       }
      0        
      0        
262             map {
263 0   0       $_->{update} = $state->{comics}->{ $_->{tag} }->{update} ||= 0;
  0            
264 0           @tm = localtime($_->{update});
265 0           $_->{updated} = sprintf( "%04d-%02d-%02d %02d:%02d:%02d",
266             1900+$tm[5], 1+$tm[4], @tm[3,2,1,0] );
267 0 0         $l_name = length($_->{name}) if $l_name < length($_->{name});
268 0 0         $l_plugin = length(ref($_)) if $l_plugin < length(ref($_));
269 0           $_;
270             } @plugins;
271              
272 0           $l_plugin -= $lpl;
273 0           $l_fetcher -= $lft;
274 0           my $fmt = "%-${l_name}s %-${l_plugin}s %-${l_fetcher}s %-8s %s\n";
275 0           foreach my $comic ( @plugins ) {
276              
277 0           my $st = $state->{comics}->{ $comic->{tag} };
278 1     1   1983 no strict 'refs';
  1         2  
  1         231  
279             printf( $fmt,
280             $comic->{name},
281             substr( ref($comic), $lpl ),
282 0           substr( ${ref($comic)."::"}{ISA}[0], $lft ),
283             $st->{disabled} ? "disabled" : "enabled",
284 0 0         $comic->{update} ? $comic->{updated} : "",
    0          
285             );
286             }
287              
288             }
289              
290 1     1   1385 use LWP::UserAgent;
  1         88648  
  1         3083  
291              
292             our $ua;
293             our $uuid;
294              
295             sub run_plugins {
296              
297 0 0   0     unless ( $ua ) {
298 0           $ua = LWP::UserAgent::Custom->new;
299 0           $uuid = uuid();
300             }
301              
302 0           foreach my $comic ( @plugins ) {
303 0 0         warn("Plugin: ", $comic->{name}, "\n") if $verbose > 1;
304              
305             # Force existence of this comic's state otherwise
306             # it will be autovivified within the fetch method
307             # and never get outside.
308 0   0       $state->{comics}->{$comic->{tag}} ||= {};
309              
310             # Make the state accessible.
311 0           $comic->{state} = $state->{comics}->{$comic->{tag}};
312              
313             # Skip is disabled.
314 0 0 0       next if $comic->{state}->{disabled} && !$force;
315              
316             # Run it, trapping errors.
317 0           $stats->{tally}++;
318 0 0         unless ( eval { $comic->fetch($reuse); 1 } ) {
  0            
  0            
319 0           $comic->{state}->{fail} = $@;
320 0           debug($comic->{state}->{fail});
321 0           push( @{ $stats->{fail} },
322 0           [ $comic->{name}, $comic->{state}->{fail} ] );
323             }
324             }
325             }
326              
327             ################ Index subroutines ################
328              
329             sub build {
330              
331             # Change to the spooldir and collect all HTML fragments.
332 0 0   0     chdir($spooldir) or die("$spooldir: $!\n");
333 0           opendir( my $dir, "." );
334 0           my @files = grep { /^[^._].+(?
  0            
335 0           close($dir);
336 0 0         warn("Number of images = ", scalar(@files), "\n") if $debug;
337 0 0         $stats->{tally} = $stats->{uptodate} = @files if $rebuild;
338              
339             # Sort the fragments on last modification date.
340             @files =
341 0           map { $_->[0] }
342 0           sort { $b->[1] <=> $a->[1] }
343 0 0         grep { $force || ! $state->{comics}->{$_->[2]}->{disabled} }
344 0           map { ( my $t = $_ ) =~ s/\.\w+$//;
  0            
345 0           [ $_, (stat($_))[9], $t ] }
346             @files;
347              
348 0 0         if ( $debug > 1 ) {
349 0           warn("Images (sorted):\n");
350 0           warn(" $_\n") for @files;
351             }
352              
353             # Creat icon.
354 0 0         unless ( -s "comics.png" ) {
355 0           require Comics::Utils::Icon;
356 0           open( my $fd, '>:raw', "comics.png" );
357 0           print $fd Comics::Utils::Icon::icon();
358 0           close($fd);
359             }
360              
361             # Create a new index.html.
362 0           open( my $fd, '>:utf8', "index.html" );
363 0           preamble($fd);
364 0           htmlstats($fd);
365 0           for ( @files ) {
366 0 0         open( my $hh, '<:utf8', $_ )
367             or die("$_: $!");
368 0           print { $fd } <$hh>;
  0            
369 0           close($hh);
370             }
371 0           postamble($fd);
372 0           close($fd);
373             }
374              
375             sub preamble {
376 0     0     my ( $fd ) = @_;
377 0           print $fd <
378            
379            
380             Comics!
381            
382            
407            
408            
409            
410            
411             EOD
412             }
413              
414             sub postamble {
415 0     0     my ( $fd ) = @_;
416 0           print $fd <
417            
418            
419            
420             EOD
421             }
422              
423             sub htmlstats {
424 0     0     my ( $fd ) = @_;
425 0           print $fd <
426            
427            
428            

[Comics]Comics

429 0           Comics $VERSION, last run: @{[ "".localtime() ]}
@{[ statmsg(1) ]}

  0            
430            

431            
432            
433             EOD
434             }
435              
436             ################ Statistics subroutines ################
437              
438             sub statistics {
439 0 0   0     return unless $verbose;
440 0           warn( statmsg(), "\n" );
441             }
442              
443             sub statmsg {
444 0     0     my ( $html ) = @_;
445 0           my $loaded = $stats->{loaded};
446 0           my $tally = $stats->{tally};
447 0           my $uptodate = $stats->{uptodate};
448 0           my $fail = @{ $stats->{fail} };
  0            
449 0           my $disabled = $stats->{disabled};
450 0           my $excluded = $stats->{excluded};
451 0           my $new = $stats->{tally} - $stats->{uptodate} - $fail;
452 0           my $res = "Number of comics = $loaded (".
453             "$new new, " .
454             "$uptodate uptodate";
455 0 0         $res .= ", $disabled disabled" if $disabled;
456 0 0         $res .= ", $excluded excluded" if $excluded;
457 0 0         if ( $fail ) {
458 0 0         if ( $html ) {
459 0           $res .= ",
460 0           for ( @{ $stats->{fail} } ) {
  0            
461 0           my $t = $_->[1];
462 0           $t =~ s/ at .*//s;
463 0           $res .= $_->[0] . " ($t) ";
464             }
465 0           $res .= "\">$fail fail";
466             }
467             else {
468 0           $res .= ", $fail fail";
469             }
470             }
471 0           return "$res)";
472             }
473              
474             ################ Miscellaneous ################
475              
476             sub spoolfile {
477 0     0     my ( $file ) = @_;
478 0           File::Spec->catfile( $spooldir, $file );
479             }
480              
481             sub uuid {
482 0     0     my @chars = ( 'a'..'f', 0..9 );
483 0           my @string;
484 0           push( @string, $chars[int(rand(16))]) for (1..32);
485 0           splice( @string, 8, 0, '-');
486 0           splice( @string, 13, 0, '-');
487 0           splice( @string, 18, 0, '-');
488 0           splice( @string, 23, 0, '-');
489 0           return join('', @string);
490             }
491              
492             sub debug {
493 0 0   0     return unless $debug;
494 0           warn(@_,"\n");
495             }
496              
497             sub debugging {
498 0     0     $debug;
499             }
500              
501             ################ Command line handling ################
502              
503             sub app_options {
504 0     0     my $help = 0; # handled locally
505 0           my $ident = 0; # handled locally
506 0           my $man = 0; # handled locally
507              
508             my $pod2usage = sub {
509             # Load Pod::Usage only if needed.
510 0     0     require Pod::Usage;
511 0           Pod::Usage->import;
512 0           &pod2usage;
513 0           };
514              
515             # Process options.
516 0 0         if ( @ARGV > 0 ) {
517             GetOptions('spooldir=s' => \$spooldir,
518             'refresh' => \$refresh,
519             'rebuild' => \$rebuild,
520             'enable' => \$activate,
521 0     0     'disable' => sub { $activate = -1 },
522             'list' => \$list,
523             'force' => \$force,
524             'reuse' => \$reuse,
525             'ident' => \$ident,
526             'verbose+' => \$verbose,
527 0     0     'quiet' => sub { $verbose = 0 },
528 0 0         'trace' => \$trace,
529             'help|?' => \$help,
530             'man' => \$man,
531             'debug' => \$debug)
532             or $pod2usage->(2);
533             }
534 0 0 0       if ( $ident or $help or $man ) {
      0        
535 0           print STDERR ("This is $my_name version $VERSION\n");
536             }
537 0 0 0       if ( $man or $help ) {
538 0 0         $pod2usage->(1) if $help;
539 0 0         $pod2usage->(VERBOSE => 2) if $man;
540             }
541             }
542              
543             ################ Documentation ################
544              
545             =head1 NAME
546              
547             Comics - Comics aggregator in the style of Gotblah
548              
549             =head1 SYNOPSIS
550              
551             perl -MComics -e 'main()' -- [options] [plugin ...]
552              
553             or
554              
555             perl Comics.pm [options] [plugin ...]
556              
557             If the associated C tool has been installed properly:
558              
559             collect [options] [plugin ...]
560              
561             Options:
562             --spooldir=XXX where resultant images and index must be stored
563             --enable enables the plugins (no aggregation)
564             --disable disables the plugins (no aggregation)
565             --list lists the plugins (no aggregation)
566             --rebuild rebuild index.html, no fetching
567             --refresh consider all images as new
568             --ident shows identification
569             --help shows a brief help message and exits
570             --man shows full documentation and exits
571             --verbose provides more verbose information
572             --quiet provides no information unless failure
573              
574             =head1 OPTIONS
575              
576             =over 8
577              
578             =item B<--spooldir=>I
579              
580             Designates the spool area. Downloaded comics and index files are
581             written here.
582              
583             =item B<--enable>
584              
585             The plugins that are named on the command line will be enabled for
586             future runs of the aggregator. Default is to enable all plugins.
587              
588             Note that when this command is used, the program exits after enabling
589             the plugins. No aggregation takes place.
590              
591             =item B<--disable>
592              
593             The plugins that are named on the command line will be disabled for
594             future runs of the aggregator. Default is to disable all plugins.
595              
596             Note that when this command is used, the program exits after disabling
597             the plugins. No aggregation takes place.
598              
599             =item B<--list>
600              
601             Provides information on the selected (default: all) plugins.
602              
603             Note that when this command is used, no aggregation takes place.
604              
605             =item B<--rebuild>
606              
607             Recreates index.html in the spooldir without fetching new comics.
608              
609             =item B<--help>
610              
611             Prints a brief help message and exits.
612              
613             =item B<--man>
614              
615             Prints the manual page and exits.
616              
617             =item B<--ident>
618              
619             Prints program identification.
620              
621             =item B<--verbose>
622              
623             Provides more verbose information. This option may be repeated for
624             even more verbose information.
625              
626             =item B<--quiet>
627              
628             Silences verbose information.
629              
630             =item I
631              
632             If present, process only the specified plugins.
633              
634             This is used for disabling and enabling plugins, but it can also be
635             used to test individual plugins.
636              
637             =back
638              
639             =head1 DESCRIPTION
640              
641             The normal task of this program is to perform aggregation. it will
642             load the available plugins and run all of them.
643              
644             The plugins will examine the contents of comics sites and update the
645             'cartoon of the day' in the spool area.
646              
647             Upon completion, an index.html is generated in the spool area to view
648             the comics collection.
649              
650             It is best to run this program from the spool area itself.
651              
652             =head2 Special commands
653              
654             Note that no aggregation is performed when using any of these commands.
655              
656             With command line option B<--list> a listing of the plugins is produced.
657              
658             Plugins can be enabled and disabled with B<--enable> and B<--disable>
659             respectively.
660              
661             =head1 PLUGINS
662              
663             B This program assumes that the plugins can be found in
664             C<../lib> relative to the location of the executable file.
665              
666             All suitable CIC<.pm> files are examined
667             and loaded.
668              
669             Plugins are derived from Fetcher classes, see below.
670              
671             See L for a fully commented plugin.
672              
673             =head1 FETCHERS
674              
675             Fetchers implement different fetch strategies. Currently provided are:
676              
677             L - fetch a comic by loading and examining a series of URLs.
678              
679             L - fetch a comic by URL.
680              
681             L - fetch a comic by examining the comic's home page.
682              
683             L - fetch a comic from a GoComics site.
684              
685             =cut
686              
687             package LWP::UserAgent::Custom;
688 1     1   14 use parent qw(LWP::UserAgent);
  1         2  
  1         9  
689              
690 1     1   2357 use HTTP::Cookies;
  1         12576  
  1         288  
691             my $cookie_jar;
692              
693             sub new {
694 0     0     my ( $pkg ) = @_;
695 0           my $self = $pkg->SUPER::new();
696 0           bless $self, $pkg;
697              
698 0           $self->agent('Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:60.0) Gecko/20100101 Firefox/60.0');
699 0           $self->timeout(10);
700 0   0       $cookie_jar ||= HTTP::Cookies->new
701             (
702             file => ::spoolfile(".lwp_cookies.dat"),
703             autosave => 1,
704             ignore_discard => 1,
705             );
706 0           $self->cookie_jar($cookie_jar);
707              
708 0           return $self;
709             }
710              
711             sub get {
712 0     0     my ( $self, $url ) = @_;
713              
714 0           my $res;
715              
716 0           my $sleep = 1;
717 0           for ( 0..4 ) {
718 0           $res = $self->SUPER::get($url);
719 0           $cookie_jar->save;
720 0 0         last if $res->is_success;
721             # Some sites block LWP queries. Show why.
722 0 0         if ( $res->status_line =~ /^403/ ) {
723 1     1   887 use Data::Dumper;
  1         12394  
  1         392  
724 0           warn(Dumper($res));
725 0           exit;
726             }
727 0 0         last if $res->status_line !~ /^5/; # not temp fail
728 0 0         print STDERR "Retry..." if $verbose;
729 0           sleep $sleep;
730 0           $sleep += $sleep;
731             }
732              
733 0           return $res;
734             }
735              
736             1;
737              
738             =head1 AUTHOR
739              
740             Johan Vromans, C<< >>
741              
742             =head1 SUPPORT
743              
744             Development of this module takes place on GitHub:
745             https://github.com/sciurius/comics .
746              
747             You can find documentation for this module with the perldoc command.
748              
749             perldoc Comics
750              
751             Please report any bugs or feature requests using the issue tracker on
752             GitHub.
753              
754             =head1 ACKNOWLEDGEMENTS
755              
756             The people behind Gotblah, for creating the original tool.
757              
758             =head1 LICENSE
759              
760             Copyright (C) 2016,2018 Johan Vromans,
761              
762             This module is free software. You can redistribute it and/or
763             modify it under the terms of the Artistic License 2.0.
764              
765             This program is distributed in the hope that it will be useful,
766             but without any warranty; without even the implied warranty of
767             merchantability or fitness for a particular purpose.
768              
769             =cut
770              
771             package main;
772              
773             unless ( caller ) {
774             main();
775             }
776              
777             1;