source: trunk/grabbers/citysearch

Last change on this file was 1370, checked in by max, 7 years ago

citysearch: Datasource format change

  • Property svn:executable set to *
File size: 17.1 KB
Line 
1#!/usr/bin/perl
2#
3# citysearch TV guide grabber
4#
5
6my $version = '4.0.0';
7
8use strict;
9use Getopt::Long;
10use POSIX;
11use Data::Dumper;
12use IO::File;
13use XMLTV;
14use HTML::TreeBuilder;
15use Shepherd::Common;
16
17# ---------------------------------------------------------------------------
18# --- Global Variables
19
20my $progname = "citysearch";
21
22my $DATASOURCE = "http://citysearch.com.au";
23
24my $lang = 'en';
25my $debug = 0;
26my $channels;
27my $opt_channels;
28my $opt = { };
29my $gaps;
30my %stats;
31my $shows;
32my $cache;
33my $runtime = time;
34my $zerohr;
35my @skipped_channels;
36
37# ---------------------------------------------------------------------------
38# --- Setup
39
40print "$progname $version\n";
41
42$| = 1;
43
44&get_command_line_options;
45
46exit 0 if ($opt->{version});
47
48&help if ($opt->{help});
49
50&set_defaults;
51
52&read_channels_file;
53
54unless ($channels)
55{
56    print "ERROR: No channels requested. Please use --channels_file.\n";
57    exit 33;
58}
59
60&read_gaps_file;
61
62&read_cache;
63
64&set_region;
65
66&get_guide_data;
67
68&calculate_stop_times;
69
70&details;
71
72&write_cache;
73
74&write_xml;
75
76&Shepherd::Common::print_stats($progname, $version, $runtime, %stats);
77
78&log("Done.");
79exit;
80
81
82# ---------------------------------------------------------------------------
83# --- Subs
84
85sub get_guide_data
86{
87    &log("Grabbing data for days " . $opt->{offset} .
88         " - " . ($opt->{days} - 1) .
89         ($opt->{output} ? " into " . $opt->{output} : '') .
90         ".");
91
92    # Calculate midnight on day zero in epoch time
93    my @today = localtime($runtime); # 0=sec,1=min,2=hour,3=day,4=month,5=year,6=wday,7=yday,8=isdst
94    $zerohr = $runtime - (($today[0]) + ($today[1]*60) + ($today[2]*60*60));
95
96    for my $day ($opt->{offset} .. ($opt->{days} - 1))
97    { 
98        my $dow = &POSIX::strftime("%A", localtime($runtime + ($day * 86400)));
99
100        &log("Day $day ($dow)");
101
102        my $start_hr = 0;
103        if (!$day)
104        {
105            $start_hr = int($today[2] / 3) * 3;
106        }
107        for (my $hr = $start_hr; $hr < 24; $hr += 3)
108        {
109
110            &log("Time window $hr:00 - " . ($hr+3) . ":00");
111
112            my $url = "$DATASOURCE/tvguide/$day/$hr:00?city=" . $opt->{'rname'};
113            my $guidedata = &Shepherd::Common::get_url($url);
114            exit 11 unless ($guidedata);
115
116            # Verify that the guide page really is for the day we want.
117            my $daystr = &POSIX::strftime("%A, %d %B", localtime($runtime + ($day * 86400)));
118            $daystr =~ s/, 0(\d)/, $1/;
119
120            unless ($guidedata =~ /<p class="date">$daystr<\/p>/)
121            {
122                &log("Exiting: couldn't locate daystring \"$daystr\" in guide page for $dow.");
123                exit 21;
124            }
125
126            &parse_guide($guidedata, $day, $hr);
127        }
128    }
129    &log("Found " . &num_items($shows) . " shows on " . scalar(keys %$shows) . " channels.");
130}
131
132sub parse_guide
133{
134    my ($guidedata, $day, $window) = @_;
135
136    &log("Parsing guide page (Day $day hr $window).") if ($debug);
137    my $tree = HTML::TreeBuilder->new;
138    $tree->no_space_compacting(1);
139    $tree->parse($guidedata);
140    $tree->eof;
141
142    foreach my $table ($tree->look_down(_tag => 'table', class => 'tvGuideTable'))
143    {
144        &log("Found table.") if ($debug);
145        foreach my $tr ($table->look_down(_tag => 'tr'))
146        {
147            my $ctag = $tr->look_down(_tag => 'td', class => 'channel');
148            next unless ($ctag);
149
150            $ctag = $ctag->look_down(_tag => 'p', class => 'channelName');
151            my $channame = $ctag->as_text;
152            my $chanid = $channels->{$channame};
153            unless ($chanid)
154            {
155                unless (grep $_ eq $channame, @skipped_channels)
156                {
157                    &log("Skipping unsubscribed channel \"$channame\".");
158                    push @skipped_channels, $channame;
159                }
160                next;
161            }
162
163            &log("Channel $channame") if ($debug);
164           
165            # When we hit a "Continue Before" block, it means we're missing
166            # a show's start time. Skip next show in this case.
167            my $continue_before = 0;
168
169            foreach my $td ($tr->look_down(_tag => 'td'))
170            {
171                my $td_class = $td->attr('class');
172                next unless ($td_class and $td_class =~ /(\d\d)(\d\d)/);
173                my $block_start = ($1 * 3600) + ($2 * 60); 
174
175                $continue_before = 1 if ($td_class =~ /continueBefore/);
176
177                foreach my $showblock ($td->look_down(_tag => 'div', class => 'programWrapper'))
178                {
179
180                    if ($continue_before)
181                    {
182                        $continue_before = 0;
183                        next;
184                    }
185
186                    my $show;
187
188                    $show->{channel} = $chanid;
189
190                    my $start = $block_start;
191                    if ($showblock->as_HTML =~ /<span class="oddStartTime">(\d+)\.(\d+)<\/span> ([ap]m)/)
192                    {
193                        $start = ($1 * 3600) + ($2 * 60);
194                        $start += (12 * 3600) if ($3 eq 'pm');
195                        $start -= (12 * 3600) if ($3 eq 'am' and $1 == 12);
196                    }
197                    $show->{start} = $zerohr + (86400 * $day) + $start;
198
199                    my $atag = $showblock->look_down(_tag => 'a');
200                    unless ($atag)
201                    {
202                        # Caused by "No information available" entries
203                        &log("Empty show block: day $day hr $window chan $channame") if ($debug);
204                        next;
205                    }
206                    $show->{title} = $atag->as_text();
207
208                    die "Missing pid: day $day hr $window chan $channame title $show->{title}" unless ($atag->attr('href') =~ m"/tvguide/viewTvProgram/tvReviews-(.*)");
209                    $show->{pid} = $1;
210
211                    #if ($showblock->as_HTML =~ /<span class="accessLink">(\w+)<\/span>/)
212                    #{
213                    #   push @{$show->{category}}, $1;
214                    #}
215
216                    if ($showblock->as_HTML =~ /Rpt/)
217                    {
218                        $show->{'previously-shown'} = { };
219                    }
220                    &log("- $show->{title}") if ($debug);
221                    $shows->{$chanid}->{$show->{start}} = $show;
222                }
223            }
224        }
225    }
226    $tree->delete;
227}
228
229sub details
230{
231    # iterate through our list, compare to cache, lookup if necessary
232    my $count = 0;
233    my $num_shows = &num_items($shows);
234    foreach my $ch (keys %$shows)
235    {
236        foreach my $s (sort keys %{$shows->{$ch}})
237        {
238            my $show = $shows->{$ch}->{$s};
239            if ($show->{start} > $zerohr + (86400 * $opt->{days}))
240            {
241                &log("Late  : " . $show->{title}) if ($debug);
242                delete $shows->{$ch}->{$s};
243            }
244            elsif ($show->{stop} and $show->{stop} < $zerohr + (86400 * $opt->{offset}))
245            {
246                &log("Early : " . $show->{title}) if ($debug);
247                delete $shows->{$ch}->{$s};
248            }
249            elsif ($gaps and &is_outside_gaps($show->{channel}, $show->{start}, $show->{stop}))
250            {
251                &log("Nongap: " . $show->{title}) if ($debug);
252                delete $shows->{$ch}->{$s};
253            }
254            elsif (
255                $cache 
256                    and 
257                $cache->{$ch} 
258                    and 
259                $cache->{$ch}->{$s}
260                    and
261                $cache->{$ch}->{$s}->{details}
262                    and
263                $cache->{$ch}->{$s}->{stop} eq $show->{stop}
264                    and
265                $cache->{$ch}->{$s}->{title} eq $show->{title})
266            {
267                &log("Cached: ". $show->{title}) if ($debug);
268                $shows->{$ch}->{$s} = $cache->{$ch}->{$s};
269                $stats{cache_hits}++;
270                $stats{shows}++;
271            }
272            else
273            {
274                &log("New   : " . $show->{title}) if ($debug);
275                my $html = &fetch_details($show->{pid});
276                if ($html)
277                {
278                    &parse_details($html, $show);
279                    $show->{details} = 1;
280                    $cache->{$ch}->{$s} = $show;
281                    $stats{shows}++;
282                }
283                else
284                {
285                    &log("Couldn't fetch " . $show->{title} .
286                         " (pid " . $show->{pid} . ")!");
287                }
288            }
289            $count++;
290            if ($count % 25 == 0)
291            {
292                &log(sprintf " ...processed %d of %d shows [%s elapsed, %d new, %d cached, %d unwanted]",
293                    $count, $num_shows, 
294                    &Shepherd::Common::pretty_duration(time - $runtime),
295                    $stats{shows} - $stats{cache_hits},
296                    $stats{cache_hits},
297                    $count - $stats{shows});
298            }
299        }
300    }
301}
302
303sub fetch_details
304{
305    my $pid = shift;
306
307    my $url = "$DATASOURCE/tvpopup/tvReviews-$pid?city=".$opt->{'rname'};
308    my $html = &Shepherd::Common::get_url($url);
309
310#    my $html = &Shepherd::Common::get_url(url => $url, referer => 'http://citysearch.com.au/tvguide/viewTvProgram/tvReviews-39204745-94-1');
311    return $html;
312}
313
314sub parse_details
315{
316    my ($html, $show) = @_;
317
318    &log("Parsing \"$show->{title}\"") if ($debug);
319    my $tree = HTML::TreeBuilder->new_from_content($html);
320
321    my $block = $tree->look_down(_tag => 'div', id => 'popupContent');
322    unless ($block)
323    {
324        print "Dumping bad HTML:\n$html\n" if ($debug);
325        print "Can't parse details page! Title: $show->{title}\n";
326        exit 22;
327    }
328
329    my $desc = $block->right();
330    if ($desc)
331    {
332        $desc = &strip_whitespace($desc->as_text);
333        $show->{desc} = $desc if ($desc);
334    } else { &log("Missing desc???\n"); }
335
336    my (%video, $category, %type);
337    foreach my $label ($block->look_down(_tag => 'label'))
338    {
339        if ($label->as_text =~ /(.*?):/)
340        {
341            my $value = $label->right()->as_text;
342            if ($1 eq 'Type')
343            {
344                $category = &strip_whitespace($value);
345            }
346            elsif ($1 eq 'Country')
347            {
348                $show->{country} = $value;
349            }
350            elsif ($1 eq 'Language')
351            {
352                $show->{language} = $value;
353            }
354            elsif ($1 eq 'Cast')
355            {
356                foreach (split /, /, $value)
357                {
358                    push @{$show->{credits}{actor}}, &strip_whitespace($_);
359                }
360            }
361            elsif ($1 eq 'Director')
362            {
363                foreach (split /, /, $value)
364                {
365                    push @{$show->{credits}{director}}, &strip_whitespace($_);
366                }
367            }
368            elsif ($1 eq 'Writer') # unseen
369            {
370                foreach (split /, /, $value)
371                {
372                    push @{$show->{credits}{writer}}, &strip_whitespace($_);
373                }
374            }
375            elsif ($1 eq 'Duration')
376            {
377                if ($2 =~ /(\d+) min/)
378                {
379                    $show->{length} = $1 * 60;
380                    if (!$show->{stop}) {
381                        $show->{stop} = $show->{start} + ($1 * 60);
382                        &log("Filled in stop time! $1 minutes.") if ($debug);
383                    }
384                }
385            }
386            elsif ($1 eq 'Format')
387            {
388                foreach my $info (split /, /, $value)
389                {
390                    $info = &strip_whitespace($info);
391                    if ($info eq 'Closed Captions')
392                    {
393                        push @{$show->{'subtitles'}}, 'teletext';
394                    }
395                    elsif ($info eq 'Subtitles')
396                    {
397                        push @{$show->{'subtitles'}}, 'onscreen';
398                    }
399                    elsif ($info eq 'Widescreen')
400                    {
401                        $video{aspect} = '16:9';
402                    }
403                    elsif ($info eq 'High Definition')
404                    {
405                        $video{'quality'} = 'HDTV';
406                    }
407                    elsif ($info eq 'Premiere')
408                    {
409                        $show->{'premiere'} = [ $info ];
410                        $type{premiere} = 1;
411                    }
412                    elsif ($info eq 'Live')
413                    {
414                        $type{live} = 1;
415                    }
416                    elsif ($info eq 'Final' || $info eq 'Finale') # unseen
417                    {
418                        $type{final} = 1;
419                    }
420                    elsif ($info eq 'Return') # unseen
421                    {
422                        $type{return} = 1;
423                    }
424                    elsif ($info eq 'Repeat')
425                    {
426                        $show->{'previously-shown'} = { };
427                    }
428                    elsif ($info eq 'Movie')
429                    {
430                        $type{movie} = 1;
431                    }
432                    else
433                    {
434                        &log("Unknown info field: \"$info\"");
435                    }
436                }
437            }
438            elsif ($1 eq 'Rating')
439            {
440                $show->{rating} = $value;
441            }
442            elsif ($1 eq 'Year')
443            {
444                $show->{date} = $value;
445            }
446            elsif ($1 eq 'Channel' or $1 eq 'Time')
447            {
448                # ignore: handled elsewhere
449            }
450            else
451            {
452                &log("Ignoring $1: $value") if ($debug);
453            }
454        }
455        else
456        {
457            &log("Unknown label: " .$label->as_text);
458        }
459    }
460    $show->{video} = { %video } if (%video);
461    $show->{category} = [ &Shepherd::Common::generate_category(
462        $show->{title}, $category, %type) ];
463
464    $tree->delete;
465
466    print "Parsed: " . Dumper($show) if ($debug);
467}
468
469sub calculate_stop_times
470{
471    foreach my $ch (keys %$shows)
472    {
473        my $last_start_time;
474        foreach my $s (reverse sort keys %{$shows->{$ch}})
475        {
476            $shows->{$ch}->{$s}->{stop} = $last_start_time if ($last_start_time);
477            $last_start_time = $shows->{$ch}->{$s}->{start};
478        }
479    }
480}
481
482sub write_xml
483{
484    my %writer_args = ( encoding => 'ISO-8859-1' );
485
486    &log("Writing " . &num_items($shows) . " shows to XML.");
487
488    if ($opt->{output})
489    {
490        my $fh = new IO::File(">" . $opt->{output})
491            or die "Can't open " . $opt->{output} . ": $!";
492        $writer_args{OUTPUT} = $fh;
493    }
494
495    my $writer = new XMLTV::Writer(%writer_args);
496
497    $writer->start
498        ( { 'source-info-url'    => $DATASOURCE,
499            'source-info-name'   => "Citysearch",
500            'generator-info-name' => "$progname $version"} );
501
502    for my $channel (sort keys %$channels)
503    {
504        $writer->write_channel( { 
505                'display-name' => [ [ $channel, $lang ] ],
506                'id' => $channels->{$channel} } );
507    }
508
509    foreach my $ch (sort keys %$shows)
510    {
511        foreach my $s (sort keys %{$shows->{$ch}})
512        {
513            # Don't return shows with no stop time
514            next unless ($shows->{$ch}->{$s}->{stop});
515
516            # Format for XMLTV-compliance
517            my %p = %{$shows->{$ch}->{$s}};
518            foreach my $field ('title', 'sub-title', 'desc', 'country')
519            {
520                $p{$field} = [[ $p{$field}, $lang ]] if ($p{$field});
521            }
522            $p{language} = [ $p{language}, $lang ] if ($p{language});
523            $p{start} = &POSIX::strftime("%Y%m%d%H%M", localtime($p{start}));
524            $p{stop} = &POSIX::strftime("%Y%m%d%H%M", localtime($p{stop}));
525            $p{rating} = [[ $p{rating}, 'ABA', undef ]] if ($p{rating});
526            if ($p{category} && ref($p{category}) eq "ARRAY"
527                    && $p{category}[0] && ref($p{category}[0]) ne "ARRAY") # obsolete 14/10/2007
528            {
529                foreach (@{$p{category}})
530                {
531                    $_ = [ &Shepherd::Common::translate_category($_), $lang ];
532                }
533            }
534            if ($p{subtitles})
535            {
536                my @s;
537                foreach (@{$p{subtitles}})
538                {
539                    push @s, { type => $_ };
540                }
541                $p{subtitles} = [ @s ];
542            }
543            delete $p{pid};
544            delete $p{details};
545
546            &log("-> " . $shows->{$ch}->{$s}->{title}) if ($debug);
547#           print Dumper(\%p);
548            $shows->{$ch}->{$s}->{start} = &POSIX::strftime("%Y%m%d%H%M", localtime($s));
549            $writer->write_programme(\%p);
550        }
551    }
552
553    $writer->end();
554}
555
556# ---------------------------------------------------------------------
557# Helper subs
558
559sub num_items
560{
561    my $hash = shift;
562    my $count = 0;
563    foreach my $ch (keys %$hash)
564    {
565        $count += scalar keys %{$hash->{$ch}};
566    }
567    return $count;
568}
569
570sub is_outside_gaps
571{
572    my ($ch, $start, $stop) = @_;
573
574    foreach my $gap (@{$gaps->{$ch}})
575    {
576        if ($gap =~ /(\d+)-(\d+)/)
577        {
578            return 0 if ($stop > $1 and $start < $2);
579        }
580    }
581    return 1;
582}
583
584sub strip_whitespace 
585{
586    $_[0] =~ /^\s*(.*?)\s*$/ ? $1 : $_[0];
587}
588
589# ---------------------------------------------------------------------
590# Setup subs
591
592
593sub read_cache
594{
595    $cache = Shepherd::Common::read_cache(\$opt->{'cache-file'});
596    if ($cache)
597    {
598        &log("Retrieved " . &num_items($cache) . " cached items from file.");
599        &clean_cache;
600    }
601    else
602    {
603        $cache = { };
604        &log("Not using cache.");
605    }
606    if ($opt->{'dump-cache'})
607    {
608        &log("Dumping cache.");
609        print Dumper($cache);
610        exit 0;
611    }
612}
613
614sub clean_cache
615{
616    my $cutoff = $runtime - 86400;   
617    &log("Removing cached shows that finish earlier than " . localtime($cutoff) . ".") if ($debug);
618    my $count = 0;
619
620    foreach my $ch (keys %$cache)
621    {
622        foreach my $s (keys %{$cache->{$ch}})
623        {
624            if ($cache->{$ch}->{$s}->{stop} < $cutoff)
625            {
626                &log("Removing $cache->{$ch}->{$s}->{title}.") if ($debug);
627                delete $cache->{$ch}->{$s};
628                $count++;
629            }
630        }
631    }
632    &log("Removed $count stale items from cache.") if ($count);
633}
634
635sub write_cache
636{
637    my $n = &num_items($cache);
638    return unless ($n);
639    &log("Writing $n shows to cache.");
640    Shepherd::Common::write_cache($opt->{'cache-file'}, $cache);
641}
642
643sub set_region
644{
645    my %regions = ( 81 => 'adelaide', 75 => 'brisbane', 126 => 'canberra',
646                    74 => 'darwin', 88 => 'hobart', 94 => 'melbourne', 
647                    101 => 'perth', 73 => 'sydney' );
648    unless ($regions{$opt->{region}})
649    {
650        &log("ERROR: unsupported region \"$opt->{region}\".");
651        exit 32;
652    }
653    $opt->{rname} = $regions{$opt->{region}};
654    &log("Datasource: $DATASOURCE (". $opt->{'rname'} . ')') if ($debug);
655}
656
657
658sub get_command_line_options
659{
660    &Getopt::Long::Configure('pass_through');
661    &GetOptions($opt, qw(
662                            help
663                            debug
664                            output=s
665                            days=i
666                            offset=i
667                            region=i
668                            dump-cache
669                            cache-file=s
670                            channels_file=s
671                            gaps_file=s
672                            version
673                            warper
674                        ));
675    $debug = $opt->{debug};
676
677    if (@ARGV)
678    {
679        &log("\nUnknown option(s): @ARGV\n");
680    }
681}
682
683sub set_defaults
684{
685    my $defaults = {
686        'days' => 7,
687        'offset' => 0,
688        'region' => 94,
689        'output' => &getcwd . '/output.xmltv',
690        'cache-file' => &getcwd . '/' . $progname . '.cache',
691        'channels_file' => &getcwd . '/channels.conf'
692    };
693
694    foreach (keys %$defaults)
695    {
696        unless (defined $opt->{$_})
697        {
698            $opt->{$_} = $defaults->{$_};
699        }
700    }
701
702    $opt->{'days'} = 7 if ($opt->{'days'} > 7);
703 
704    &Shepherd::Common::set_defaults(
705        stats => \%stats,
706        delay => "1-5",
707        debug => $debug,
708        webwarper => $opt->{warper}
709        );
710
711    &Shepherd::Common::setup_ua( cookie_jar => 1 );
712
713    # Initialize stats
714    %stats = ( );
715    foreach (qw( cache_hits shows ))
716    {
717        $stats{$_} = 0;
718    }
719}
720
721sub read_channels_file 
722{
723    &read_config_file('channels', 'channels_file');
724}
725
726sub read_gaps_file
727{
728    &read_config_file('gaps', 'gaps_file');
729    if ($gaps)
730    {
731        foreach (keys %$gaps)
732        {
733            $gaps->{$channels->{$_}} = $gaps->{$_};
734            delete $gaps->{$_};
735        }
736    }
737}
738
739sub read_config_file
740{
741    my ($name, $arg) = @_;
742
743    return unless ($opt->{$arg});
744    &log("Reading $name file: $opt->{$arg}");
745    if (-r $opt->{$arg})
746    {
747        local (@ARGV, $/) = ($opt->{$arg});
748        no warnings 'all';
749        eval <>;
750        die "Can't parse $name file: $@" if ($@);
751    }
752    else
753    {
754        &log("Unable to read $name file.");
755    }
756}
757
758sub log
759{
760    &Shepherd::Common::log(@_);
761}
762
763sub help
764{
765    print q{
766Command-line options:
767  --help                 Print this message
768  --version              Show current version
769
770  --output <file>        Write XML into the specified file
771  --channels_file <file> Read channel subscriptions from file
772
773  --region <n>           Grab data for region code <n>
774  --days <n>             Grab <n> days of data (today being day 1)
775  --offset <n>           Skip the first <n> days
776
777  --debug                Print lots of debugging output
778};
779    exit 0;
780}
781
Note: See TracBrowser for help on using the repository browser.