blib/lib/HTML/Scrape.pm | |||
---|---|---|---|
Criterion | Covered | Total | % |
statement | 117 | 121 | 96.6 |
branch | 43 | 48 | 89.5 |
condition | 17 | 21 | 80.9 |
subroutine | 13 | 13 | 100.0 |
pod | 2 | 2 | 100.0 |
total | 192 | 205 | 93.6 |
line | stmt | bran | cond | sub | pod | time | code |
---|---|---|---|---|---|---|---|
1 | package HTML::Scrape; | ||||||
2 | |||||||
3 | 3 | 3 | 210604 | use 5.10.1; | |||
3 | 20 | ||||||
4 | 3 | 3 | 16 | use strict; | |||
3 | 5 | ||||||
3 | 55 | ||||||
5 | 3 | 3 | 12 | use warnings; | |||
3 | 5 | ||||||
3 | 174 | ||||||
6 | |||||||
7 | =head1 NAME | ||||||
8 | |||||||
9 | HTML::Scrape - Helper functions for scraping text from HTML tags | ||||||
10 | |||||||
11 | =head1 VERSION | ||||||
12 | |||||||
13 | Version 0.3.0 | ||||||
14 | |||||||
15 | =cut | ||||||
16 | |||||||
17 | our $VERSION = '0.3.0'; | ||||||
18 | |||||||
19 | our $WARNINGS = 1; | ||||||
20 | |||||||
21 | 3 | 3 | 1729 | use HTML::Parser; | |||
3 | 17455 | ||||||
3 | 102 | ||||||
22 | 3 | 3 | 1504 | use HTML::TokeParser; | |||
3 | 12010 | ||||||
3 | 91 | ||||||
23 | 3 | 3 | 21 | use HTML::Tagset; | |||
3 | 6 | ||||||
3 | 3186 | ||||||
24 | |||||||
25 | |||||||
26 | =head1 SYNOPSIS | ||||||
27 | |||||||
28 | Handy helpers for common HTML scraping tasks. | ||||||
29 | |||||||
30 | use HTML::Scrape; | ||||||
31 | |||||||
32 | my $ids = HTML::Scrape::scrape_all_ids( $html ); | ||||||
33 | |||||||
34 | =head1 WARNINGS | ||||||
35 | |||||||
36 | You can enable parsing warnings by setting C<$HTML::Scrape::WARNINGS> | ||||||
37 | to a true value. By default, no warnings are emitted. | ||||||
38 | |||||||
39 | =head1 NOTES FOR FUTURE DOCS | ||||||
40 | |||||||
41 | If a tag exists but has no content, including empty tags like C<< >>, |
||||||
42 | then it will have an empty string for content. This way you can test | ||||||
43 | for existence of these tags. | ||||||
44 | |||||||
45 | =head1 FUNCTIONS | ||||||
46 | |||||||
47 | =head2 scrape_id( $id, $html ) | ||||||
48 | |||||||
49 | Scrapes the text of the single ID C<$id> from C<$html>. | ||||||
50 | |||||||
51 | =cut | ||||||
52 | |||||||
53 | sub scrape_id { | ||||||
54 | 23 | 23 | 1 | 22772 | my $id = shift; | ||
55 | 23 | 41 | my $html = shift; | ||||
56 | |||||||
57 | 23 | 45 | my $all_ids = scrape_all_ids( $html, $id ); | ||||
58 | |||||||
59 | 23 | 101 | return $all_ids->{$id}; | ||||
60 | } | ||||||
61 | |||||||
62 | |||||||
63 | =head2 scrape_all_ids( $html [, $specific_id ] ) | ||||||
64 | |||||||
65 | Parses the entire web page and returns all the text in a hashref keyed on ID. | ||||||
66 | |||||||
67 | If you pass in C<$specific_id>, then only that ID will be scraped, | ||||||
68 | and parsing will stop once it is found. The better way to do this is by | ||||||
69 | calling C |
||||||
70 | |||||||
71 | =cut | ||||||
72 | |||||||
73 | sub scrape_all_ids { | ||||||
74 | 47 | 47 | 1 | 12186 | my $html = shift; | ||
75 | 47 | 64 | my $wanted_id = shift; | ||||
76 | |||||||
77 | 47 | 233 | my $p = HTML::Parser->new( | ||||
78 | start_h => [ \&_parser_handle_start, 'self, tagname, attr, line, column' ], | ||||||
79 | end_h => [ \&_parser_handle_end, 'self, tagname, line, column' ], | ||||||
80 | text_h => [ \&_parser_handle_text, 'self, dtext' ], | ||||||
81 | ); | ||||||
82 | 47 | 2771 | $p->{stack} = []; | ||||
83 | 47 | 100 | $p->{ids} = {}; | ||||
84 | 47 | 100 | 114 | if ( defined $wanted_id ) { | |||
85 | 42 | 76 | $p->{wanted_id} = $wanted_id; | ||||
86 | } | ||||||
87 | |||||||
88 | 47 | 161 | $p->empty_element_tags(1); | ||||
89 | 47 | 50 | 325 | $p->parse($html) if defined($html); | |||
90 | 47 | 140 | $p->eof; | ||||
91 | |||||||
92 | 47 | 100 | 92 | if ( !defined $wanted_id ) { | |||
93 | # With a wanted_id, we would have stopped parsing early and left tags on the stack, so don't check. | ||||||
94 | 5 | 100 | 7 | if ( my $n = scalar @{$p->{stack}} ) { | |||
5 | 29 | ||||||
95 | 1 | 4 | _warn( "$n tag(s) unclosed at end of document: " . join( ', ', map { $_->[0] } @{$p->{stack}} ) ); | ||||
6 | 26 | ||||||
1 | 3 | ||||||
96 | } | ||||||
97 | } | ||||||
98 | |||||||
99 | 47 | 250 | return $p->{ids}; | ||||
100 | } | ||||||
101 | |||||||
102 | |||||||
103 | sub _parser_handle_start { | ||||||
104 | 624 | 624 | 1006 | my $parser = shift; | |||
105 | 624 | 768 | my $tagname = shift; | ||||
106 | 624 | 720 | my $attr = shift; | ||||
107 | 624 | 721 | my $line = shift; | ||||
108 | 624 | 726 | my $column = shift; | ||||
109 | |||||||
110 | 624 | 873 | my $id = $attr->{id}; | ||||
111 | |||||||
112 | 624 | 100 | 1491 | if ( $HTML::Tagset::emptyElement{$tagname} ) { | |||
113 | 68 | 100 | 100 | 206 | if ( $tagname eq 'br' || $tagname eq 'hr' ) { | ||
114 | 48 | 71 | _parser_handle_text( $parser, ' ' ); | ||||
115 | } | ||||||
116 | |||||||
117 | 68 | 100 | 120 | if ( $id ) { | |||
118 | 14 | 100 | 26 | if ( defined($parser->{wanted_id}) ) { | |||
119 | 12 | 100 | 26 | if ( $id eq $parser->{wanted_id} ) { | |||
120 | 4 | 8 | $parser->{ids}{$id} = ''; | ||||
121 | 4 | 13 | $parser->eof; | ||||
122 | 4 | 12 | return; | ||||
123 | } | ||||||
124 | } | ||||||
125 | else { | ||||||
126 | 2 | 4 | $parser->{ids}{$id} = ''; | ||||
127 | } | ||||||
128 | } | ||||||
129 | |||||||
130 | 64 | 224 | return; | ||||
131 | } | ||||||
132 | |||||||
133 | # Add space if this tag is one that causes whitespace when rendered. | ||||||
134 | 556 | 100 | 66 | 1708 | if ( $tagname eq 'br' || !$HTML::Tagset::isPhraseMarkup{$tagname} ) { | ||
135 | 365 | 591 | _parser_handle_text( $parser, ' ' ); | ||||
136 | } | ||||||
137 | |||||||
138 | # If it's a dupe ID, warn and ignore the ID. | ||||||
139 | 556 | 50 | 66 | 1124 | if ( defined($id) && exists $parser->{ids}{$id} ) { | ||
140 | 0 | 0 | _warn( "Duplicate ID $id found in <$tagname> at $line:$column" ); | ||||
141 | 0 | 0 | $id = undef; | ||||
142 | } | ||||||
143 | |||||||
144 | 556 | 741 | my $stack = $parser->{stack}; | ||||
145 | |||||||
146 | # Tags like and |
||||||
147 | # For example: | ||||||
148 | # |
||||||
149 | # |
||||||
150 | # |
||||||
151 | # | ||||||
152 | 556 | 100 | 66 | 1085 | if ( $HTML::Tagset::optionalEndTag{$tagname} && @{$stack} && $stack->[-1][0] eq $tagname ) { | ||
134 | 100 | 469 | |||||
153 | 36 | 50 | my $item = pop @{$stack}; | ||||
36 | 48 | ||||||
154 | 36 | 65 | _close_tag( $parser, $item ); | ||||
155 | } | ||||||
156 | |||||||
157 | 556 | 865 | push @{$stack}, [ $tagname, $id, '' ]; | ||||
556 | 1268 | ||||||
158 | |||||||
159 | 556 | 2108 | return; | ||||
160 | } | ||||||
161 | |||||||
162 | |||||||
163 | sub _parser_handle_end { | ||||||
164 | 458 | 458 | 697 | my $parser = shift; | |||
165 | 458 | 571 | my $tagname = shift; | ||||
166 | 458 | 555 | my $line = shift; | ||||
167 | 458 | 523 | my $column = shift; | ||||
168 | |||||||
169 | 458 | 100 | 898 | return if $HTML::Tagset::emptyElement{$tagname}; | |||
170 | |||||||
171 | 436 | 540 | my $stack = $parser->{stack}; | ||||
172 | |||||||
173 | # Deal with tags that close others. Implicitly close the previous tag if it's li, dt, dd or p. | ||||||
174 | 436 | 50 | 480 | if ( @{$stack} ) { | |||
436 | 802 | ||||||
175 | 436 | 549 | my $previous_item = $stack->[-1]; | ||||
176 | 436 | 527 | my $previous_tagname = $previous_item->[0]; | ||||
177 | |||||||
178 | my $this_tag_closes_previous_one = | ||||||
179 | ( $tagname ne $previous_tagname ) | ||||||
180 | && | ||||||
181 | ( | ||||||
182 | ( ($tagname eq 'ul' || $tagname eq 'ol') && $previous_tagname eq 'li' ) | ||||||
183 | || | ||||||
184 | ( ($tagname eq 'dl') && ($previous_tagname eq 'dt' || $previous_tagname eq 'dd') ) | ||||||
185 | || | ||||||
186 | 436 | 100 | 905 | ( !$HTML::Tagset::isPhraseMarkup{$tagname} && $previous_tagname eq 'p' ) | |||
187 | ) | ||||||
188 | ; | ||||||
189 | 436 | 100 | 693 | if ( $this_tag_closes_previous_one ) { | |||
190 | 14 | 21 | _close_tag( $parser, pop @{$stack} ); | ||||
14 | 24 | ||||||
191 | } | ||||||
192 | } | ||||||
193 | |||||||
194 | 436 | 50 | 535 | if ( !@{$stack} ) { | |||
436 | 773 | ||||||
195 | 0 | 0 | _warn( "Unexpected closing $tagname> at $line:$column" ); | ||||
196 | 0 | 0 | return; | ||||
197 | } | ||||||
198 | 436 | 100 | 762 | if ( $tagname ne $stack->[-1][0] ) { | |||
199 | 4 | 20 | _warn( "Unexpected closing $tagname> at $line:$column: Expecting $stack->[-1][0]>" ); | ||||
200 | 4 | 13 | return; | ||||
201 | } | ||||||
202 | |||||||
203 | 432 | 546 | _close_tag( $parser, pop @{$stack} ); | ||||
432 | 869 | ||||||
204 | |||||||
205 | # Add space if this tag is one that causes whitespace when rendered. | ||||||
206 | 432 | 100 | 66 | 1444 | if ( $tagname eq 'br' || !$HTML::Tagset::isPhraseMarkup{$tagname} ) { | ||
207 | 241 | 388 | _parser_handle_text( $parser, ' ' ); | ||||
208 | } | ||||||
209 | |||||||
210 | 432 | 1315 | return; | ||||
211 | } | ||||||
212 | |||||||
213 | |||||||
214 | sub _parser_handle_text { | ||||||
215 | 1615 | 1615 | 2226 | my $parser = shift; | |||
216 | 1615 | 2041 | my $text = shift; | ||||
217 | |||||||
218 | 1615 | 1867 | for my $item ( @{$parser->{stack}} ) { | ||||
1615 | 2694 | ||||||
219 | 6481 | 100 | 10570 | if ( $item->[1] ) { # Only accumulate text for tags with IDs. | |||
220 | 1356 | 2063 | $item->[2] .= $text; | ||||
221 | } | ||||||
222 | } | ||||||
223 | |||||||
224 | 1615 | 4425 | return; | ||||
225 | } | ||||||
226 | |||||||
227 | |||||||
228 | sub _close_tag { | ||||||
229 | 482 | 482 | 561 | my $parser = shift; | |||
230 | 482 | 547 | my $item = shift; | ||||
231 | |||||||
232 | 482 | 538 | my (undef, $id, $text) = @{$item}; | ||||
482 | 880 | ||||||
233 | 482 | 100 | 827 | if ( defined $id ) { | |||
234 | 137 | 185 | my $keepit; | ||||
235 | |||||||
236 | 137 | 100 | 249 | if ( defined $parser->{wanted_id} ) { | |||
237 | # We're searching for a specific ID. | ||||||
238 | 109 | 100 | 181 | if ( $id eq $parser->{wanted_id} ) { | |||
239 | 34 | 51 | $keepit = 1; | ||||
240 | 34 | 76 | $parser->eof; | ||||
241 | } | ||||||
242 | else { | ||||||
243 | # No need to keep the text of an ID we don't care about. | ||||||
244 | } | ||||||
245 | } | ||||||
246 | else { | ||||||
247 | 28 | 38 | $keepit = 1; | ||||
248 | } | ||||||
249 | |||||||
250 | 137 | 100 | 232 | if ( $keepit ) { | |||
251 | 62 | 225 | $text =~ s/^\s+//; | ||||
252 | 62 | 327 | $text =~ s/\s+$//; | ||||
253 | 62 | 481 | $text =~ s/\s+/ /g; | ||||
254 | 62 | 159 | $parser->{ids}{$id} = $text; | ||||
255 | } | ||||||
256 | } | ||||||
257 | |||||||
258 | 482 | 681 | return; | ||||
259 | } | ||||||
260 | |||||||
261 | |||||||
262 | sub _warn { | ||||||
263 | 5 | 50 | 5 | 48 | warn @_, "\n" if $WARNINGS; | ||
264 | |||||||
265 | 5 | 28 | return; | ||||
266 | } | ||||||
267 | |||||||
268 | |||||||
269 | =head1 AUTHOR | ||||||
270 | |||||||
271 | Andy Lester, C<< |
||||||
272 | |||||||
273 | =head1 BUGS | ||||||
274 | |||||||
275 | Please report any bugs or feature requests at L |
||||||
276 | |||||||
277 | =head1 SUPPORT | ||||||
278 | |||||||
279 | You can find documentation for this module with the perldoc command. | ||||||
280 | |||||||
281 | perldoc HTML::Scrape | ||||||
282 | |||||||
283 | You can also look for information at: | ||||||
284 | |||||||
285 | =over 4 | ||||||
286 | |||||||
287 | =item * Search CPAN | ||||||
288 | |||||||
289 | L |
||||||
290 | |||||||
291 | =back | ||||||
292 | |||||||
293 | =head1 LICENSE AND COPYRIGHT | ||||||
294 | |||||||
295 | This software is Copyright (c) 2023 by Andy Lester. | ||||||
296 | |||||||
297 | This is free software, licensed under: The Artistic License 2.0 (GPL Compatible) | ||||||
298 | |||||||
299 | =cut | ||||||
300 | |||||||
301 | 1; # End of HTML::Scrape |