File Coverage

blib/lib/App/Rssfilter/Rule.pm
Criterion Covered Total %
statement 118 128 92.1
branch 27 48 56.2
condition 3 5 60.0
subroutine 28 30 93.3
pod n/a
total 176 211 83.4


line stmt bran cond sub pod time code
1             # ABSTRACT: match and filter RSS feeds
2              
3 17     17   250037 use strict;
  17         64  
  17         734  
4 17     17   101 use warnings;
  17         35  
  17         1224  
5              
6              
7              
8             package App::Rssfilter::Rule;
9             {
10             $App::Rssfilter::Rule::VERSION = '0.07';
11             }
12              
13 17     17   7843 use Moo;
  17         81968  
  17         171  
14 17     17   60064 use Method::Signatures;
  17         225323  
  17         186  
15 17     17   12462 use Module::Runtime qw<>;
  17         44  
  17         316  
16 17     17   19116 use Class::Inspector qw<>;
  17         67266  
  17         1241  
17             with 'App::Rssfilter::Logger';
18              
19              
20              
21             has condition => (
22             is => 'ro',
23             required => 1,
24             );
25              
26              
27             has _match => (
28             is => 'lazy',
29             init_arg => undef,
30             default => method { $self->coerce_attr( attr => $self->condition, type => 'match' ) },
31 17 50   17   870459 );
  3 50   3   6  
  3         14  
  3         5  
  3         11  
32 3         76  
33              
34             method match( $item ) {
35             return $self->_match->( $item );
36             }
37              
38              
39             has condition_name => (
40             is => 'lazy',
41             default => method { $self->nice_name_for( $self->condition, 'matcher' ) },
42             );
43              
44              
45             has action => (
46             is => 'ro',
47             required => 1,
48             );
49              
50 17 50   17   64065  
  1 50   1   3  
  1         5  
  1         2  
  1         4  
51 1         7 has _filter => (
52             is => 'lazy',
53             init_arg => undef,
54             default => method { $self->coerce_attr( attr => $self->action, type => 'filter' ) },
55             );
56              
57 1         1201  
58             method filter( $item ) {
59             $self->logger->debugf(
60             'applying %s since %s matched %s',
61             $self->action_name,
62             $self->condition_name,
63             $item->find('guid, link, title')->first->text
64             );
65 17 50 66 17   73103 return $self->_filter->( $item, $self->condition_name );
  3 50   3   29040  
  3 50       25  
  3         46  
  3         906608  
  3         14  
66 3         5 }
67 3     3   34  
  3         2359  
  1         1232  
  0         0  
68 2         145  
69             has action_name => (
70             is => 'lazy',
71             default => method { $self->nice_name_for( $self->action, 'filter' ) },
72             );
73 17 50   17   89375  
  2 50   2   7  
  2 50       9  
  2         5  
  2         8  
  2         5  
  2         7  
74 17     17   2499  
  17         50  
  17         2725  
75 17     17   21207 method constrain( Mojo::DOM $Mojo_DOM ) {
  17         22191  
  17         257  
76 2         6 my $count = 0;
77 2         6 $Mojo_DOM->find( 'item' )->grep( sub { $self->match( shift ) } )->each( sub { $self->filter( shift ); ++$count; } );
  0         0  
78 2         5 return $count;
  2         41  
79 0         0 }
  0         0  
80              
81             # internal helper methods
82              
83 17     17   46391 method nice_name_for( $attr, $type ) {
  9     9   33547  
  9         55  
84 9 100       48 use feature 'switch';
85 1         6 use experimental 'smartmatch';
86 1         4 given( ref $attr ) {
87             when( 'CODE' ) { return "unnamed RSS ${type}"; }
88 9         223 when( q{} ) { return $attr }
89             default { return $_ }
90             }
91 17 50   17   50899 }
  9 50   9   224  
  9         33  
  9         21  
  9         34  
92              
93 9         165 method BUILDARGS( %args ) {
94             if ( 1 == keys %args ) {
95             @args{'condition','action'} = each %args;
96 17 50   17   68112 delete $args{ $args{ condition } };
  15 50   15   25  
  15         80  
  15         33  
  15         34  
  15         22  
  15         27  
  15         47  
  15         378  
97 15 50       39 }
98 17     17   3368 return \%args;
  17         42  
  17         1795  
99 17     17   126 }
  17         49  
  17         129  
100 15         39  
101 15         66 method BUILD( $args ) {
102 7         147 # validate coercion of condition & action
103             $self->$_ for qw< _filter _match >;
104 8         15 }
105 6         25  
106             method coerce_attr( :$attr, :$type ) {
107 2         3 die "can't use an undefined value to $type RSS items" if not defined $attr;
108 2 50       13 use feature 'switch';
109 0     0   0 use experimental 'smartmatch';
110 0         0 given( ref $attr ) {
111             when( 'CODE' ) {
112             return $attr;
113 2         35 }
114             when( q{} ) { # not a ref
115             return $self->coerce_module_name_to_sub( module_name => $attr, type => $type );
116             }
117             default {
118             if( my $method = $attr->can( $type ) ) {
119 17 50   17   69113 return sub { $attr->$type( @_ ); }
  6 50   6   10  
  6         27  
  6         16  
  6         11  
  6         12  
  6         14  
  6         21  
  6         26  
120 6         42 }
121             else
122             {
123             die "${_}::$type does not exist";
124             }
125             }
126             }
127             }
128              
129             method coerce_module_name_to_sub( :$module_name, :$type ) {
130             my ($namespace, $additional_args) =
131 6   50     42 $module_name =~ m/
132 6 50       24 \A
133 0         0 ( [^\[]+ ) # namespace
134             (?: # followed by optional
135             \[
136 6         13 ( .* ) # additional arguments
137             \] # in square brackets
138 6 50       39 )? # optional, remember?
139 0         0 \z
140             /xms;
141             my @additional_args = split q{,}, $additional_args // q{};
142             if( $namespace !~ /::/xms ) {
143 6         314 $namespace = join q{::}, qw< App Rssfilter >, ucfirst( $type ), $namespace;
144 6 100       88 }
145 2         7  
146             $namespace =~ s/\A :: //xms; # '::anything'->can() will die
147              
148             if( not Class::Inspector->loaded( $namespace ) ) {
149 6 100       70 Module::Runtime::require_module $namespace;
150 2 50       5 }
151              
152 2     2   59 # create an object if we got an OO package
153 2         53 my $invocant = $namespace;
154             if( $namespace->can( 'new' ) ) {
155             $invocant = $namespace->new( @additional_args );
156             }
157              
158 0     0   0 # return a wrapper
159 0         0 if( my $method = $invocant->can( $type ) ) {
160             if( $invocant eq $namespace ) {
161             return sub {
162             $method->( @_, @additional_args) ;
163             };
164 4         103 }
165             else
166             {
167             return sub {
168             $invocant->$method( @_ );
169             };
170             }
171             }
172             else
173             {
174             die "${namespace}::$type does not exist";
175             }
176             }
177             1;
178              
179             __END__
180              
181             =pod
182              
183             =encoding UTF-8
184              
185             =head1 NAME
186              
187             App::Rssfilter::Rule - match and filter RSS feeds
188              
189             =head1 VERSION
190              
191             version 0.07
192              
193             =head1 SYNOPSIS
194              
195             use App::RssFilter::Rule;
196              
197             use Mojo::DOM;
198             my $rss = Mojo::DOM->new( 'an RSS document' );
199              
200             my $delete_duplicates_rule = App::Rssfilter::Rule->new( Duplicate => 'DeleteItem' );
201              
202             # shorthand for
203             $delete_duplicates_rule = App::Rssfilter::Rule->new(
204             condition => 'App::Rssfilter::Match::Duplicate',
205             action => 'App::Rssfilter::Filter::DeleteItem',
206             );
207              
208             # apply rule to RSS document
209             $delete_duplicates_rule->constrain( $rss );
210              
211             # write modules with match and filter subs
212              
213             package MyMatcher::LevelOfInterest;
214            
215             sub new {
216             my ( $class, @bracketed_args) = @_;
217             if ( grep { $_ eq 'BORING' } @bracketed_args ) {
218             # turn on boredom detection circuits
219             ...
220             }
221             ...
222             }
223            
224             sub match {
225             my ( $self, $mojo_dom_rss_item ) = @_;
226             ...
227             }
228              
229             package MyFilter::MakeMoreInteresting;
230              
231             sub filter {
232             my ( $reason_for_match,
233             $matched_mojo_dom_rss_item,
234             @bracketed_args ) = @_;
235             ...
236             }
237              
238             package main;
239              
240             my $boring_made_interesting_rule = App::Rssfilter::Rule->new(
241             'MyMatcher::LevelOfInterest[BORING]'
242             => 'MyFilter::MakeMoreInteresting[glitter,lasers]'
243             );
244             $boring_made_interesting_rule->constrain( $rss );
245              
246             my $interesting_with_decoration_rule = App::Rssfilter::Rule->new(
247             condition => MyMatcher::LevelOfInterest->new('OUT_OF_SIGHT'),
248             condition_name => 'ReallyInteresting', # instead of plain 'MyMatcher::LevelOfInterest'
249             action => 'MyFilter::MakeMoreInteresting[ascii_art]',
250             );
251             $interesting_with_decoration_rule->constrain( $rss );
252              
253             # or use anonymous subs
254             my $space_the_final_frontier_rule = App::Rssfilter:Rule->new(
255             condition => sub {
256             my ( $item_to_match ) = @_;
257             return $item_to_match->title->text =~ / \b space \b /ixms;
258             },
259             action => sub {
260             my ( $reason_for_match, $matched_item ) = @_;
261             my @to_check = ( $matched_item->tree );
262             my %seen;
263             while( my $elem = pop @to_check ) {
264             next if 'ARRAY' ne ref $elem or $seen{ $elem }++;
265             if( $elem->[0] eq 'text' ) {
266             $elem->[1] =~ s/ \b space \b /\& (the final frontier)/xmsig;
267             }
268             else
269             {
270             push @to_check, @{ $elem };
271             }
272             }
273             },
274             );
275             $space_the_final_frontier_rule->constrain( $rss );
276              
277             ### or with an App::Rssfilter feed or group
278              
279             use App::RssFilter::Feed;
280             my $feed = App::RssFilter::Feed->new( 'examples' => 'http://example.org/e.g.rss' );
281             $feed->add_rule( 'My::Matcher' => 'My::Filter' );
282             # same as
283             $feed->add_rule( App::Rssfilter::Rule->new( 'My::Matcher' => 'My::Filter' ) );
284             $feed->update;
285              
286             =head1 DESCRIPTION
287              
288             This module will test all C<item> elements in a L<Mojo::DOM> object against a condition, and apply an action on items where the condition is true.
289              
290             It consumes the L<App::Rssfilter::Logger> role.
291              
292             =head1 ATTRIBUTES
293              
294             =head2 logger
295              
296             This is a object used for logging; it defaults to a L<Log::Any> object. It is provided by the L<App::Rssfilter::Logger> role.
297              
298             =head2 condition
299              
300             This is the module, object, or coderef to use to match C<item> elements for filtering. Modules are passed as strings, and must contain a C<match> sub. Object must have a C<match> method.
301              
302             =head2 _match
303              
304             This is a coderef created from this rule's condition which will be used by L</match> to check RSS items. It is automatically coerced from the C<condition> attribute and cannot be passed to the constructor.
305              
306             If this rule's condition is an object, C<_match> will store a wrapper which calls the C<match> method of the object.
307             If this rule's condition is a subref, C<_match> will store the same subref.
308              
309             If this rule's condition is a string, it is treated as a namespace. If the string is not a fully-specified namespace, it will be changed to C<App::Rssfilter::Match::I<<string>>>; if you really want to use C<&TopLevelNamespace::match>, specify C<condition> as C<'::TopLevelNamespace'> (or directly as C<\&TopLevelNameSpace::match>). Additional arguments can be passed to the matcher by appending then to the string, separated by commas, surrounded by square brackets.
310              
311             C<_match> will then be set to a wrapper:
312              
313             =over 4
314              
315             =item *
316              
317             If C<I<< <namespace> >>::new> exists, C<_match> will be set as if C<condition> had originally been the object returned from calling C<I<< <namespace> >>::new( @additional_arguments )>.
318              
319             =item *
320              
321             Otherwise, C<_match> will store a wrapper which calls C<I<< <namespace> >>::match( $rss_item, @additional_arguments )>.
322              
323             =back
324              
325             =head2 condition_name
326              
327             This is a nice name for the condition, which will be used as the reason for the match given to the action. Defaults to the class of the condition, or its value if it is a simple scalar, or C<unnamed RSS matcher> otherwise.
328              
329             =head2 action
330              
331             This is the module, object, or coderef to use to filter C<item> elements matched by this rule's condition. Modules are passed as strings, and must contain a C<filter> sub. Object must have a C<filter> method.
332              
333             =head2 _filter
334              
335             This is a coderef created from this rule's action which will be used by L</filter> to check RSS items. It is automatically coerced from the C<action> attribute and cannot be passed to the constructor.
336              
337             If this rule's action is an object, C<_filter> will store a wrapper which calls the C<filter> method of the object.
338             If this rule's action is a subref, C<_filter> will store the same subref.
339              
340             If the rule's action is a string, it is treated as a namespace. If the string is not a fully-specified namespace, it will be changed to C<App::Rssfilter::filter::I<<string>>>; if you really want to use C<&TopLevelNamespace::filter>, specify C<action> as C<'::TopLevelNamespace'> (or directly as C<\&TopLevelNameSpace::filter>). Additional arguments can be passed to the filter by appending then to the string, separated by commas, surrounded by square brackets.
341              
342             The filter will then be set to a wrapper:
343              
344             =over 4
345              
346             =item *
347              
348             If C<I<< <namespace> >>::new> exists, C<_filter> will be set as if C<action> had originally been the object returned from calling C<I<< <namespace> >>::new( @additional_arguments )>.
349              
350             =item *
351              
352             Otherwise, C<_filter> will store a wrapper which calls C<I<< <namespace> >>::filter( $rss_item, @additional_arguments )>.
353              
354             =back
355              
356             =head2 action_name
357              
358             This is a nice name for the action. Defaults to the class of the action, or its value if it is a simple scalar, or C<unnamed RSS filter> otherwise.
359              
360             =head1 METHODS
361              
362             =head2 match
363              
364             my $did_match = $self->match( $item_element_from_Mojo_DOM );
365              
366             Returns the result of testing this rule's condition against C<$item>.
367              
368             =head2 filter
369              
370             $self->filter( $item_element_from_Mojo_DOM );
371              
372             Applies this rule's action to C<$item>.
373              
374             =head2 constrain
375              
376             my $count_of_filtered_items = $rule->constrain( $Mojo_DOM );
377              
378             Gathers all child item elements of C<$Mojo_DOM> for which the condition is true, and applies the action to each. Returns the number of items that were matched (and filtered).
379              
380             =head1 SEE ALSO
381              
382             =over 4
383              
384             =item *
385              
386             L<App::Rssfilter::Match::AbcPreviews>
387              
388             =item *
389              
390             L<App::Rssfilter::Match::BbcSports>
391              
392             =item *
393              
394             L<App::Rssfilter::Match::Category>
395              
396             =item *
397              
398             L<App::Rssfilter::Match::Duplicates>
399              
400             =item *
401              
402             L<App::Rssfilter::Filter::MarkTitle>
403              
404             =item *
405              
406             L<App::Rssfilter::Filter::DeleteItem>
407              
408             =item *
409              
410             L<App::RssFilter::Group>
411              
412             =item *
413              
414             L<App::RssFilter::Feed>
415              
416             =item *
417              
418             L<App::RssFilter>
419              
420             =back
421              
422             =head1 AUTHOR
423              
424             Daniel Holz <dgholz@gmail.com>
425              
426             =head1 COPYRIGHT AND LICENSE
427              
428             This software is copyright (c) 2013 by Daniel Holz.
429              
430             This is free software; you can redistribute it and/or modify it under
431             the same terms as the Perl 5 programming language system itself.
432              
433             =cut