File Coverage

blib/lib/App/Rssfilter/Rule.pm
Criterion Covered Total %
statement 128 128 100.0
branch 31 48 64.5
condition 4 5 80.0
subroutine 30 30 100.0
pod n/a
total 193 211 91.4


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