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