File Coverage

blib/lib/Mojo/Feed.pm
Criterion Covered Total %
statement 129 134 96.2
branch 36 44 81.8
condition 21 31 67.7
subroutine 28 29 96.5
pod 5 6 83.3
total 219 244 89.7


line stmt bran cond sub pod time code
1             package Mojo::Feed;
2 12     12   2041434 use Mojo::Base '-base';
  12         594161  
  12         89  
3 12     12   4039 use Mojo::DOM;
  12         68693  
  12         332  
4 12     12   1686 use Mojo::File;
  12         80985  
  12         575  
5 12     12   1655 use Mojo::URL;
  12         25100  
  12         87  
6 12     12   2334 use Mojo::UserAgent;
  12         626254  
  12         96  
7 12     12   478 use Mojo::Util qw(decode trim);
  12         62  
  12         822  
8              
9 12     12   95 use Carp qw(croak);
  12         35  
  12         684  
10 12     12   81 use List::Util;
  12         22  
  12         842  
11 12     12   5055 use HTTP::Date qw(str2time);
  12         17485  
  12         851  
12              
13 12     12   5916 use Mojo::Feed::Item;
  12         96  
  12         96  
14              
15             use overload
16 71     71   71860 bool => sub { shift->is_valid },
17 15     15   11040 '""' => sub { shift->to_string },
18 12     12   1198 fallback => 1;
  12         27  
  12         96  
19              
20             our $VERSION = "0.21";
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             # The top node, not the root
60             has top => sub { shift->dom->children->first };
61              
62             has feed_type => sub {
63             my $top = shift->top;
64             my $tag = $top->tag;
65             my $version = $top->attr('version');
66             my $ns = $top->namespace;
67             return
68             ($tag =~ /feed/i)
69             ? ($version)
70             ? 'Atom ' . $version
71             : 'Atom 1.0'
72             : ($tag =~ /rss/i) ? 'RSS ' . $version
73             : ($tag =~ /rdf/i) ? 'RSS 1.0'
74             : 'unknown';
75             };
76              
77             has namespaces => sub {
78             my $top = shift->top;
79             my $namespaces = { atom => $top->namespace }; # only Atom feeds declare a namespace?
80             my $attrs = $top->attr;
81             for my $at (keys %$attrs) {
82             if ($at =~ /xmlns\:(\w+)/) { # extra namespace declaration
83             $namespaces->{$1} = $attrs->{$at};
84             }
85             }
86             return $namespaces;
87             };
88              
89             my %generic = (
90             description => ['description', 'tagline', 'subtitle'],
91             published => [
92             'published', 'pubDate', 'dc|date', 'created',
93             'issued', 'updated', 'modified'
94             ],
95             author => ['author', 'dc|creator', 'webMaster', 'copyright'],
96             title => ['title'],
97             subtitle => ['subtitle', 'tagline'],
98             link => ['link:not([rel])', 'link[rel=alternate]'],
99             );
100              
101             sub _get_selector {
102 331     331   2133 my ($self, $k) = @_;
103 331         536 for my $generic (@{$generic{$k}}) {
  331         953  
104 704 100       1418209 if (my $p = $self->dom->at("channel > $generic, feed > $generic", %{$self->namespaces})) {
  701         81897  
105 282 100 100     191436 if ($k eq 'author' && $p->at('name')) {
106 22         4372 return trim $p->at('name')->text;
107             }
108 260   100     3834 my $text = trim($p->text || $p->content || $p->attr('href') || '');
109 260 100       13961 if ($k eq 'published') {
110 47         347 return str2time($text);
111             }
112 213         2046 return $text;
113             }
114             }
115             };
116              
117             sub _set_selector {
118 2     2   16 my ($self, $k, $val) = @_;
119 2         4 for my $generic (@{$generic{$k}}) {
  2         7  
120 2 50       5 if (my $p = $self->dom->at("channel > $generic, feed > $generic", %{$self->namespaces})) {
  2         15  
121 2 50 33     1090 if ($k eq 'author' && $p->at('name')) {
122 0         0 return $p->at('name')->content($val);
123             }
124 2 50       7 if ($k eq 'published') {
125 0         0 return $p->content(Mojo::Date->new($val)->to_datetime()); # let's pretend we're all OK with Atom dates
126             }
127 2         10 return $p->content($val);
128             }
129             }
130             };
131             foreach my $k (keys %generic) {
132             has $k => sub { return shift->_get_selector($k) || undef; };
133             }
134              
135             has items => sub {
136             my $self = shift;
137             $self->dom->find('item, entry')
138             ->map(sub { Mojo::Feed::Item->new(dom => $_, feed => $self) });
139             };
140              
141             # alias
142 0     0 1 0 sub entries { shift->items() };
143              
144             # change the underlying DOM when we change the items list:
145             sub set_items {
146 1     1 0 66 my ($self, $new_items) = @_;
147 1     2   5 $self->dom->find('item, entry')->each(sub { $_->remove() });
  2         2101  
148 1     1   146 $new_items->each(sub { $self->top->append($_->dom) });
  1         11  
149 1         463 return $self->items($new_items);
150             };
151              
152             has is_valid => sub {
153             shift->dom->children->first->tag =~ /^(feed|rss|rdf|rdf:rdf)$/i;
154             };
155              
156             sub is_feed_content_type {
157 110     110 1 1681 my ($self, $content_type) = @_;
158             # use split to remove charset attribute from content_type header
159 110         607 ($content_type) = split(/[; ]+/, $content_type);
160             # feed mime-types:
161 110         450 my @feed_types = (
162             'application/x.atom+xml', 'application/atom+xml',
163             'application/xml', 'text/xml',
164             'application/rss+xml', 'application/rdf+xml'
165             );
166 110     530   892 return List::Util::first { $_ eq $content_type } @feed_types;
  530         1428  
167             }
168              
169              
170             sub _load {
171 40     40   112 my ($self) = @_;
172 40         157 my $tx = $self->ua->get($self->url);
173 40         404655 my $result = $tx->result; # this will croak on network errors
174              
175 40 50 66     1153 if ($result->is_error) {
    100          
176 0         0 $self->is_valid(undef);
177 0         0 croak "Error getting feed from url ", $self->url, ": ", $result->message;
178             }
179              
180             # Redirect:
181             elsif ($result->code == 301 || $result->code == 302) {
182 12         425 my $new_url = Mojo::URL->new($result->headers->location);
183 12         1332 push @{$self->redirects}, $self->url;
  12         74  
184 12         116 $self->url($new_url);
185 12 100       95 if (@{$self->redirects} > $self->max_redirects) {
  12         40  
186 2         32 $self->is_valid(undef);
187 2         745 croak "Number of redirects exceeded when loading feed"
188             }
189 10         143 return $self->_load();
190             }
191              
192             # Is this a feed (by content type)?
193 28 100       896 if ($self->is_feed_content_type($result->headers->content_type)) {
194 17 50       75 $self->charset($result->content->charset) if ($result->content->charset);
195 17         435 return $result->body;
196             }
197             else {
198             # we are in a web page. PHEAR.
199              
200             # Set real (absolute) URL (is this only relevant for testing?):
201 11 100       43 if ($self->url ne $tx->req->url) {
202 5         1058 push @{$self->redirects}, $self->url; # for logging?
  5         20  
203 5         32 $self->url($tx->req->url);
204             }
205 11         2738 my @feeds = $self->find_feed_links($result);
206              
207 11 100       46 if (@feeds) {
208 5         9 push @{$self->redirects}, $self->url; # not really a redirect, but save it
  5         17  
209 5         47 $self->url(shift @feeds);
210              
211             # save any remaining feed links as related
212 5 100       33 push @{$self->related}, @feeds if (@feeds);
  2         8  
213 5         16 return $self->_load();
214             }
215             else {
216             # call me crazy, but maybe this is just a feed served as HTML?
217 6         35 my $test = Mojo::Feed->new( url => $self->url, body => $result->body );
218 6 100       314 $test->charset($result->content->charset) if ($result->content->charset);
219 6 100       295 if ($test->is_valid) {
220             # can't avoid parsing twice;
221             # body is probably being called in the dom initializer
222             # :(
223             # $self->dom($test->dom);
224 5 50       225500 $self->charset($test->charset) if ($test->charset);
225 5         124 return $test->body;
226             }
227             else {
228 1         4302 $self->is_valid(undef);
229 1         8 croak "No valid feed found at ", $self->url;
230             }
231             }
232             }
233             }
234              
235             sub find_feed_links {
236 32     32 1 221 my ($self, $result) = @_;
237 32         74 my @feeds;
238              
239             # Find feed link elements in HEAD:
240 32   33     197 my $base
241             = Mojo::URL->new(
242             $result->dom->find('head base')->map('attr', 'href')->join('') || $self->url)
243             ->to_abs($self->url);
244 32   33     1000486 my $title
245             = $result->dom->find('head > title')->map('text')->join('') || $self->url;
246             $result->dom->find('head link')->each(sub {
247 338     338   140685 my $attrs = $_->attr();
248 338 100       4595 return unless ($attrs->{'rel'});
249 332         1067 my %rel = map { $_ => 1 } split /\s+/, lc($attrs->{'rel'});
  332         1019  
250 332 100       870 my $type = ($attrs->{'type'}) ? lc trim $attrs->{'type'} : undef;
251 332 50 100     1505 if ($type && $self->is_feed_content_type($type)
      33        
      66        
252             && ($rel{'alternate'} || $rel{'service.feed'}))
253             {
254 21         129 push @feeds, Mojo::URL->new($attrs->{'href'})->to_abs($base);
255             }
256 32         181064 });
257              
258             # Find feed links ()
259 32         74305 state $feed_exp = qr/((\.(?:rss|xml|rdf)$)|(\/feed\/*$)|(feeds*\.))/;
260             $result->dom->find('a')->grep(sub {
261 854 50   854   215331 $_->attr('href') && $_->attr('href') =~ /$feed_exp/io;
262             })->each(sub {
263 8     8   2716 push @feeds, Mojo::URL->new($_->attr('href'))->to_abs($base);
264 32         250 });
265 32         23171 return @feeds;
266             }
267              
268             sub to_hash {
269 28     28 1 151 my $self = shift;
270 28   100     168 my $hash = {map { $_ => '' . ($self->$_ || '') } (keys %generic)};
  168         30351  
271 28         15543 $hash->{items} = $self->items->map('to_hash')->to_array;
272 28         483 return $hash;
273             }
274              
275             sub to_string {
276 15     15 1 36 my $self = shift;
277 15         84 foreach my $k (keys %generic) {
278 90 100 100     10020 if ($self->$k && $self->$k ne $self->_get_selector($k)) {
279             # write it to the DOM:
280 2         9 $self->_set_selector($k, $self->$k);
281             }
282             }
283 15     31   4430 $self->items->each(sub { $_->to_string }); # maybe break this out to a sync method
  31         8060  
284 15         7269 $self->dom->to_string;
285             }
286              
287             1;
288             __END__