File Coverage

blib/lib/Mojo/Feed.pm
Criterion Covered Total %
statement 98 101 97.0
branch 25 30 83.3
condition 12 20 60.0
subroutine 22 23 95.6
pod 5 5 100.0
total 162 179 90.5


line stmt bran cond sub pod time code
1             package Mojo::Feed;
2 12     12   2043693 use Mojo::Base '-base';
  12         594507  
  12         77  
3 12     12   3934 use Mojo::DOM;
  12         68675  
  12         373  
4 12     12   1673 use Mojo::File;
  12         83031  
  12         544  
5 12     12   1606 use Mojo::URL;
  12         24889  
  12         116  
6 12     12   2264 use Mojo::UserAgent;
  12         638368  
  12         83  
7 12     12   494 use Mojo::Util qw(decode trim);
  12         56  
  12         738  
8              
9 12     12   77 use Carp qw(croak);
  12         25  
  12         644  
10 12     12   78 use List::Util;
  12         41  
  12         770  
11 12     12   4674 use HTTP::Date qw(str2time);
  12         16153  
  12         751  
12              
13 12     12   5282 use Mojo::Feed::Item;
  12         82  
  12         82  
14              
15             use overload
16 71     71   101575 bool => sub { shift->is_valid },
17 12     12   11497 '""' => sub { shift->to_string },
18 12     12   1131 fallback => 1;
  12         23  
  12         86  
19              
20             our $VERSION = "0.20";
21              
22             has charset => 'UTF-8';
23              
24             has ua => sub { Mojo::UserAgent->new() };
25             has max_redirects => sub { $ENV{MOJO_MAX_REDIRECTS} || 3 };
26             has redirects => sub { [] };
27             has related => sub { [] };
28              
29             has url => sub { Mojo::URL->new() };
30             has file => sub { Mojo::File->new() };
31             has source => sub {
32             my $self = shift;
33             return
34             ($self->url ne '') ? $self->url
35             : (-f $self->file) ? $self->file
36             : undef;
37             };
38              
39             has body => sub {
40             my $self = shift;
41             if ($self->url ne '') {
42             return $self->_load();
43             }
44             else { # skip file tests, just slurp (for Mojo::Asset::File)
45             return $self->file->slurp();
46             }
47             };
48              
49             has text => sub {
50             my $self = shift;
51             return decode($self->charset, $self->body) || '';
52             };
53              
54             has dom => sub {
55             my ($self) = @_;
56             return Mojo::DOM->new($self->text);
57             };
58              
59             has root => sub { shift->dom->children->first };
60              
61             has feed_type => sub {
62             my $top = shift->root;
63             my $tag = $top->tag;
64             my $version = $top->attr('version');
65             my $ns = $top->namespace;
66             return
67             ($tag =~ /feed/i)
68             ? ($version)
69             ? 'Atom ' . $version
70             : 'Atom 1.0'
71             : ($tag =~ /rss/i) ? 'RSS ' . $version
72             : ($tag =~ /rdf/i) ? 'RSS 1.0'
73             : 'unknown';
74             };
75              
76             has namespaces => sub {
77             my $top = shift->root;
78             my $namespaces = { atom => $top->namespace }; # only Atom feeds declare a namespace?
79             my $attrs = $top->attr;
80             for my $at (keys %$attrs) {
81             if ($at =~ /xmlns\:(\w+)/) { # extra namespace declaration
82             $namespaces->{$1} = $attrs->{$at};
83             }
84             }
85             return $namespaces;
86             };
87              
88             my %generic = (
89             description => ['description', 'tagline', 'subtitle'],
90             published => [
91             'published', 'pubDate', 'dc|date', 'created',
92             'issued', 'updated', 'modified'
93             ],
94             author => ['author', 'dc|creator', 'webMaster', 'copyright'],
95             title => ['title'],
96             subtitle => ['subtitle', 'tagline'],
97             link => ['link:not([rel])', 'link[rel=alternate]'],
98             );
99              
100             foreach my $k (keys %generic) {
101             has $k => sub {
102             my $self = shift;
103             for my $generic (@{$generic{$k}}) {
104             if (my $p = $self->dom->at("channel > $generic, feed > $generic", %{$self->namespaces})) {
105             if ($k eq 'author' && $p->at('name')) {
106             return trim $p->at('name')->text;
107             }
108             my $text = trim($p->text || $p->content || $p->attr('href') || '');
109             if ($k eq 'published') {
110             return str2time($text);
111             }
112             return $text;
113             }
114             }
115             return;
116             };
117             }
118              
119             has items => sub {
120             my $self = shift;
121             $self->dom->find('item, entry')
122             ->map(sub { Mojo::Feed::Item->new(dom => $_, feed => $self) });
123             };
124              
125             # alias
126 0     0 1 0 sub entries { shift->items() };
127              
128             has is_valid => sub {
129             shift->dom->children->first->tag =~ /^(feed|rss|rdf|rdf:rdf)$/i;
130             };
131              
132             sub is_feed_content_type {
133 110     110 1 1366 my ($self, $content_type) = @_;
134             # use split to remove charset attribute from content_type header
135 110         459 ($content_type) = split(/[; ]+/, $content_type);
136             # feed mime-types:
137 110         385 my @feed_types = (
138             'application/x.atom+xml', 'application/atom+xml',
139             'application/xml', 'text/xml',
140             'application/rss+xml', 'application/rdf+xml'
141             );
142 110     530   669 return List::Util::first { $_ eq $content_type } @feed_types;
  530         1131  
143             }
144              
145              
146             sub _load {
147 40     40   106 my ($self) = @_;
148 40         151 my $tx = $self->ua->get($self->url);
149 40         375350 my $result = $tx->result; # this will croak on network errors
150              
151 40 50 66     1080 if ($result->is_error) {
    100          
152 0         0 $self->is_valid(undef);
153 0         0 croak "Error getting feed from url ", $self->url, ": ", $result->message;
154             }
155              
156             # Redirect:
157             elsif ($result->code == 301 || $result->code == 302) {
158 12         391 my $new_url = Mojo::URL->new($result->headers->location);
159 12         1327 push @{$self->redirects}, $self->url;
  12         66  
160 12         109 $self->url($new_url);
161 12 100       64 if (@{$self->redirects} > $self->max_redirects) {
  12         72  
162 2         30 $self->is_valid(undef);
163 2         696 croak "Number of redirects exceeded when loading feed"
164             }
165 10         116 return $self->_load();
166             }
167              
168             # Is this a feed (by content type)?
169 28 100       914 if ($self->is_feed_content_type($result->headers->content_type)) {
170 17 50       82 $self->charset($result->content->charset) if ($result->content->charset);
171 17         448 return $result->body;
172             }
173             else {
174             # we are in a web page. PHEAR.
175              
176             # Set real (absolute) URL (is this only relevant for testing?):
177 11 100       45 if ($self->url ne $tx->req->url) {
178 5         1058 push @{$self->redirects}, $self->url; # for logging?
  5         22  
179 5         32 $self->url($tx->req->url);
180             }
181 11         2625 my @feeds = $self->find_feed_links($result);
182              
183 11 100       40 if (@feeds) {
184 5         8 push @{$self->redirects}, $self->url; # not really a redirect, but save it
  5         18  
185 5         47 $self->url(shift @feeds);
186              
187             # save any remaining feed links as related
188 5 100       36 push @{$self->related}, @feeds if (@feeds);
  2         8  
189 5         19 return $self->_load();
190             }
191             else {
192             # call me crazy, but maybe this is just a feed served as HTML?
193 6         35 my $test = Mojo::Feed->new( url => $self->url, body => $result->body );
194 6 100       275 $test->charset($result->content->charset) if ($result->content->charset);
195 6 100       289 if ($test->is_valid) {
196             # can't avoid parsing twice;
197             # body is probably being called in the dom initializer
198             # :(
199             # $self->dom($test->dom);
200 5 50       230411 $self->charset($test->charset) if ($test->charset);
201 5         107 return $test->body;
202             }
203             else {
204 1         4246 $self->is_valid(undef);
205 1         8 croak "No valid feed found at ", $self->url;
206             }
207             }
208             }
209             }
210              
211             sub find_feed_links {
212 32     32 1 188 my ($self, $result) = @_;
213 32         64 my @feeds;
214              
215             # Find feed link elements in HEAD:
216 32   33     169 my $base
217             = Mojo::URL->new(
218             $result->dom->find('head base')->map('attr', 'href')->join('') || $self->url)
219             ->to_abs($self->url);
220 32   33     936377 my $title
221             = $result->dom->find('head > title')->map('text')->join('') || $self->url;
222             $result->dom->find('head link')->each(sub {
223 338     338   124721 my $attrs = $_->attr();
224 338 100       4015 return unless ($attrs->{'rel'});
225 332         913 my %rel = map { $_ => 1 } split /\s+/, lc($attrs->{'rel'});
  332         923  
226 332 100       719 my $type = ($attrs->{'type'}) ? lc trim $attrs->{'type'} : undef;
227 332 50 100     1298 if ($type && $self->is_feed_content_type($type)
      33        
      66        
228             && ($rel{'alternate'} || $rel{'service.feed'}))
229             {
230 21         84 push @feeds, Mojo::URL->new($attrs->{'href'})->to_abs($base);
231             }
232 32         168141 });
233              
234             # Find feed links ()
235 32         73606 state $feed_exp = qr/((\.(?:rss|xml|rdf)$)|(\/feed\/*$)|(feeds*\.))/;
236             $result->dom->find('a')->grep(sub {
237 854 50   854   198721 $_->attr('href') && $_->attr('href') =~ /$feed_exp/io;
238             })->each(sub {
239 8     8   2336 push @feeds, Mojo::URL->new($_->attr('href'))->to_abs($base);
240 32         163 });
241 32         20014 return @feeds;
242             }
243              
244             sub to_hash {
245 29     29 1 135 my $self = shift;
246 29   100     145 my $hash = {map { $_ => '' . ($self->$_ || '') } (keys %generic)};
  174         7676  
247 29         205 $hash->{items} = $self->items->map('to_hash')->to_array;
248 29         486 return $hash;
249             }
250              
251             sub to_string {
252 12     12 1 49 shift->dom->to_string;
253             }
254              
255             1;
256             __END__