File Coverage

blib/lib/Web/PageMeta.pm
Criterion Covered Total %
statement 92 92 100.0
branch 16 22 72.7
condition 6 6 100.0
subroutine 19 19 100.0
pod n/a
total 133 139 95.6


line stmt bran cond sub pod time code
1             package Web::PageMeta;
2              
3             our $VERSION = '0.02';
4              
5 3     3   416170 use 5.010;
  3         29  
6 3     3   1570 use Moose;
  3         1232137  
  3         17  
7 3     3   20633 use MooseX::Types::URI qw(Uri);
  3         550052  
  3         16  
8 3     3   4929 use URI;
  3         5  
  3         60  
9 3     3   14 use URI::QueryParam;
  3         5  
  3         63  
10 3     3   1622 use Log::Any qw($log);
  3         21955  
  3         14  
11 3     3   7515 use Future 0.44;
  3         26254  
  3         87  
12 3     3   1289 use Future::AsyncAwait;
  3         5376  
  3         25  
13 3     3   1353 use Future::HTTP::AnyEvent;
  3         276580  
  3         119  
14 3     3   1501 use Web::Scraper;
  3         188176  
  3         22  
15 3     3   1616 use Encode qw(find_mime_encoding);
  3         42488  
  3         254  
16 3     3   24 use Time::HiRes qw(time);
  3         6  
  3         27  
17 3     3   1618 use HTTP::Exception;
  3         14897  
  3         16  
18              
19 3     3   125073 use namespace::autoclean;
  3         7  
  3         27  
20              
21             has 'url' => (
22             isa => Uri,
23             is => 'ro',
24             required => 1,
25             coerce => 1,
26             );
27              
28             has 'title' => (
29             isa => 'Str',
30             is => 'ro',
31             lazy => 1,
32             default => sub {return $_[0]->page_meta->{title} // ''},
33             );
34             has 'image' => (
35             isa => 'Str',
36             is => 'ro',
37             lazy => 1,
38             default => sub {return $_[0]->page_meta->{image} // ''},
39             );
40             has 'description' => (
41             isa => 'Str',
42             is => 'ro',
43             lazy => 1,
44             default => sub {return $_[0]->page_meta->{description} // ''},
45             );
46              
47             has 'image_data' => (
48             isa => 'Str',
49             is => 'ro',
50             lazy => 1,
51             default => sub {$_[0]->fetch_image_data_ft->get},
52             );
53              
54             has 'page_meta' => (
55             isa => 'HashRef',
56             is => 'rw',
57             lazy => 1,
58             default => sub {$_[0]->fetch_page_meta_ft->get},
59             );
60              
61             has 'fetch_page_meta_ft' => (
62             isa => 'Future',
63             is => 'ro',
64             lazy => 1,
65             builder => '_build__fetch_page_meta_ft',
66             );
67              
68             has 'fetch_image_data_ft' => (
69             isa => 'Future',
70             is => 'ro',
71             lazy => 1,
72             builder => '_build__fetch_image_data_ft',
73             );
74              
75             has '_ua' => (
76             is => 'ro',
77             lazy => 1,
78             default => sub {Future::HTTP::AnyEvent->new()},
79             );
80              
81             has '_html_meta_scraper' => (
82             is => 'ro',
83             lazy => 1,
84             builder => '_build__html_meta_scraper',
85             );
86              
87             has 'extra_scraper' => (
88             is => 'ro',
89             predicate => 'has_extra_scraper',
90             );
91              
92             sub _build__html_meta_scraper {
93             state $html_meta_scraper = scraper {
94 11     11   63991 process '/html/head/meta[contains(@property, "og:")]',
95             'head_meta_og[]' => {
96             key => '@property',
97             val => '@content',
98             };
99 11         28575 process '/html/head/title', 'title' => 'TEXT';
100 11         9095 process '/html/head/meta[@name="description"]', 'description' => '@content';
101 11         20072 process '/html/head/base', 'base_href' => '@href';
102 11     11   38 };
103 11         242 return $html_meta_scraper;
104             }
105              
106 14     14   24 async sub _build__fetch_page_meta_ft {
107 14         32 my ($self) = @_;
108              
109             # await url htmp http download
110 14         42 my $timer = time();
111 14         287 my ($body, $headers) =
112             await $self->_ua->http_get($self->url, headers => {'Accept' => 'text/html',},);
113 14         407522 my $status = _get_update_status_reason($headers);
114 14         405 $log->debugf('page meta fetch %d %s finished in %.3fs', $status, $self->url, time() - $timer);
115             HTTP::Exception->throw($status, status_message => $headers->{Reason})
116 14 100       119 if ($status != 200);
117              
118             # turn body to utf-8
119 11 100       32 if (my $content_type = $headers->{'content-type'}) {
120 3 50       20 if (my ($charset) = ($content_type =~ m/\bcharset=(.+)/)) {
121 3 50       17 if (my $decoder = find_mime_encoding($charset)) {
122 3         389 $body = $decoder->decode($body);
123             }
124             }
125             }
126              
127             # scrape default head meta
128 11         298 my $scraper_data = $self->_html_meta_scraper->scrape(\$body);
129             my %page_meta = (
130             title => $scraper_data->{title} // '',
131 11   100     12052 description => $scraper_data->{description} // '',
      100        
132             );
133 11   100     26 foreach my $attr (@{$scraper_data->{head_meta_og} // []}) {
  11         43  
134 19         33 my $key = $attr->{key};
135 19         27 my $val = $attr->{val};
136 19 50       83 next unless $key =~ m/^og:(.+)$/;
137 19         64 $page_meta{$1} = $val;
138             }
139              
140             # do any other extra scraping
141 11 100       538 if ($self->has_extra_scraper) {
142 1         24 my $escraper_data = $self->extra_scraper->scrape(\$body);
143 1         124964 foreach my $key (keys %{$escraper_data}) {
  1         5  
144 1         4 $page_meta{$key} = $escraper_data->{$key};
145             }
146             }
147              
148             # make image links absolute
149 11 50       35 if ($page_meta{image}) {
150             my $base_url = (
151             $scraper_data->{base_href}
152 11 100       279 ? URI::WithBase->new($scraper_data->{base_href}, $self->url)->abs->as_string
153             : $self->url
154             );
155 11         577 $page_meta{image} = URI::WithBase->new($page_meta{image}, $base_url)->abs->as_string;
156             }
157              
158 11         3503 return $self->page_meta(\%page_meta);
159             }
160              
161 8     8   16 async sub _build__fetch_image_data_ft {
162 8         13 my ($self) = @_;
163              
164             # await for image link
165 8         169 await $self->fetch_page_meta_ft;
166 8         517 my $fetch_url = $self->image;
167 8 50       18 return $self->{image_data} = ''
168             unless $fetch_url;
169              
170             # await image http download
171 8         27 my $timer = time();
172 8         191 my ($body, $headers) = await $self->_ua->http_get($fetch_url);
173 8         21314 my $status = _get_update_status_reason($headers);
174 8         225 $log->debugf('img fetch %d %s for %s finished in %.3fs',
175             $status, $fetch_url, $self->url, time() - $timer);
176             HTTP::Exception->throw($status, status_message => $headers->{Reason})
177 8 50       50 if ($status != 200);
178              
179 8         56 return $self->{image_data} = $body;
180             }
181              
182             sub _get_update_status_reason {
183 22     22   45 my ($headers) = @_;
184 22         40 my $status = $headers->{Status};
185 22 100       85 unless (HTTP::Status::status_message($status)) {
186 1         9 $headers->{Reason} = sprintf('(%d) %s', $status, $headers->{Reason});
187 1         3 $status = $headers->{Status} = 503;
188             }
189 22         127 return $status;
190             }
191              
192             __PACKAGE__->meta->make_immutable;
193              
194             1;
195              
196             __END__
197              
198             =head1 NAME
199              
200             Web::PageMeta - get page open-graph / meta data
201              
202             =head1 SYNOPSIS
203              
204             use Web::PageMeta;
205             my $page = Web::PageMeta->new(url => "https://www.apa.at/");
206             say $page->title;
207             say $page->image;
208              
209             async fetch previews and images:
210              
211             use Web::PageMeta;
212             my @urls = qw(
213             https://www.apa.at/
214             http://www.diepresse.at/
215             https://metacpan.org/
216             https://github.com/
217             );
218             my @page_views = map { Web::PageMeta->new( url => $_ ) }
219             @urls;
220             Future->wait_all( map { $_->fetch_image_data_ft, } @page_views )->get;
221             foreach my $pv (@page_views) {
222             say 'title> '.$pv->title;
223             say 'img_size> '.length($pv->image_data);
224             }
225              
226             # alternativelly instead of Future->wait_all()
227             use Future::Utils qw( fmap_void );
228             fmap_void(
229             sub { return $_[0]->fetch_image_data_ft },
230             foreach => [@page_views],
231             concurrent => 3
232             )->get;
233              
234             =head1 DESCRIPTION
235              
236             Get (not only) open-graph web page meta data. can be used in both normal
237             and async code.
238              
239             For any other than 200 http status codes during data downloads,
240             L<HTTP::Exception> is thrown.
241              
242             =head1 ACCESSORS
243              
244             =head2 new
245              
246             Constructor, only L</url> is required.
247              
248             =head2 url
249              
250             HTTP url to fetch data from.
251              
252             =head2 title
253              
254             Returns title of the page.
255              
256             =head2 description
257              
258             Returns description of the page.
259              
260             =head2 image
261              
262             Returns image location of the page.
263              
264             =head2 image_data
265              
266             Returns image binary data of L</image> link.
267              
268             =head2 page_meta
269              
270             Returns hash ref with all open-graph data.
271              
272             =head2 extra_scraper
273              
274             L<Web::Scrape> object to fetch image, title or description from different
275             than default location.
276              
277             use Web::Scrape;
278             use Web::PageMeta;
279             my $escraper = scraper {
280             process_first '.slider .camera_wrap div', 'image' => '@data-src';
281             };
282             my $wmeta = Web::PageMeta->new(
283             url => 'https://www.meon.eu/',
284             extra_scraper => $escraper,
285             );
286              
287             =head2 fetch_page_meta_ft
288              
289             Returns future object for fetching paga meta data. See L</"ASYNC USE">.
290             On done L</page_meta> hash is returned.
291              
292             =head2 fetch_image_data_ft
293              
294             Returns future object for fetching image data. See L</"ASYNC USE">
295             On done L</image_data> scalar is returned.
296              
297             =head1 ASYNC USE
298              
299             To run multiple page meta data or image http requests in parallel or
300             to be used in async programs L</fetch_page_meta_ft> and L<fetch_image_data_ft>
301             returning L<Future> object can be used. See L</SYNOPSIS> or F<t/02_async.t>
302             for sample use.
303              
304             =head1 SEE ALSO
305              
306             L<https://ogp.me/>
307              
308             =head1 AUTHOR
309              
310             Jozef Kutej, C<< <jkutej at cpan.org> >>
311              
312             =head1 LICENSE AND COPYRIGHT
313              
314             Copyright 2021 jkutej@cpan.org
315              
316             This program is free software; you can redistribute it and/or modify it
317             under the terms of either: the GNU General Public License as published
318             by the Free Software Foundation; or the Artistic License.
319              
320             See http://dev.perl.org/licenses/ for more information.
321              
322             =cut