File Coverage

blib/lib/Number/Phone/NANP/Vanity.pm
Criterion Covered Total %
statement 1 3 33.3
branch n/a
condition n/a
subroutine 1 1 100.0
pod n/a
total 2 4 50.0


line stmt bran cond sub pod time code
1             package Number::Phone::NANP::Vanity;
2              
3 1     1   37508 use Moose;
  0            
  0            
4             use Locale::Maketext::Simple;
5             use true;
6              
7             our $VERSION = '0.06';
8              
9             #
10             # ATTRIBUTES
11             #
12              
13             has 'number' => (
14             is => 'ro',
15             isa => 'Num',
16             required => 1,
17             );
18              
19             has [qw/npa nxx sub nxx_sub sub1 sub2/] => (
20             is => 'ro',
21             isa => 'Num',
22             lazy_build => 1,
23             );
24              
25             has 'dictionary' => (
26             is => 'ro',
27             isa => 'ArrayRef[Str|CodeRef]',
28             traits => ['Array'],
29             default => sub { [] },
30             handles => {
31             'add_to_dictionary' => 'push',
32             }
33             );
34              
35             has 'keypad_layout' => (
36             is => 'rw',
37             isa => 'HashRef[Num]',
38             traits => ['Hash'],
39             default => sub { +{ # force array context
40             split(//, uc('a2b2c2d3e3f3g4h4i4j5k5l5m6n6o6p7q7r7s7t8u8v8w9x9y9z9'))
41             }},
42             );
43              
44             has 'score' => (
45             is => 'rw',
46             isa => 'HashRef[ArrayRef]',
47             traits => ['Hash'],
48             default => sub { {} },
49             handles => {
50             '_add_to_score' => 'set',
51             'score_values' => 'values',
52             },
53             );
54              
55             # Moose::Meta::Attribute::Native::Trait::Array
56             has 'rules' => (
57             is => 'ro',
58             isa => 'ArrayRef[Str|CodeRef]',
59             traits => ['Array'],
60             default => sub {
61             [qw/
62             npa_eq_nxx
63             sub1_eq_sub2
64             nxx_repeats
65             sub_repeats
66             nxx_sub_repeats
67             sub1_repeats
68             sub2_repeats
69             nxx_sub_is_sequential_asc
70             nxx_sub_is_sequential_desc
71             sub_is_sequential_asc
72             sub_is_sequential_desc
73             last_three_pairs_repeat
74             matches_dictionary
75             digit_repeats
76             /]
77             },
78             handles => {
79             'add_rule' => 'push',
80             }
81             );
82              
83             #
84             # ATTRIBUTE BUILDERS
85             #
86              
87             sub _build_npa {
88             return substr($_[0]->number, 0, 3);
89             }
90              
91             sub _build_nxx {
92             return substr($_[0]->number, 3, 3);
93             }
94              
95             sub _build_sub {
96             return substr($_[0]->number, 6, 4);
97             }
98              
99             sub _build_nxx_sub {
100             return $_[0]->nxx . $_[0]->sub;
101             }
102              
103             sub _build_sub1 {
104             return substr($_[0]->sub, 0, 2);
105             }
106              
107             sub _build_sub2 {
108             return substr($_[0]->sub, 2, 2);
109             }
110              
111             #
112             # METHODS
113             #
114              
115             sub from_string {
116             my ($class, $string, @new) = @_;
117            
118             # remove all non-numeric chars
119             $string =~ s/\D//g;
120             # strip leading 1 (country code)
121             $string =~ s/^1//;
122            
123             die "Does not look like a valid NANP number ($string)"
124             unless length($string) == 10;
125            
126             return $class->new(number => $string, @new);
127             }
128              
129             sub number_formatted {
130             my ($self, $format) = @_;
131             $format ||= '%s-%s-%s%s';
132             return sprintf($format, $self->npa, $self->nxx, $self->sub1, $self->sub2);
133             }
134              
135             sub calculate_score {
136             my $self = shift;
137            
138             # run every rule and calculate the score
139             foreach my $rule (@{$self->rules}) {
140             my $method = ref $rule eq 'CODE' ? $rule : '_rule_' . $rule;
141             my ($score, $description) = $self->$method;
142            
143             # do not record zero scores
144             next unless $score;
145            
146             # get default message if one is not returned by _rule sub
147             # [_1] [_2] [_3] [_4] [_5] [_6]
148             $description = loc($rule, $score, $self->npa, $self->nxx, $self->sub, $self->sub1, $self->sub2)
149             if !$description;
150            
151             $self->_add_to_score($rule => [$score, $description]);
152             }
153              
154             return $self->_total_score;
155             }
156              
157             sub _total_score {
158             my $self = shift;
159            
160             my $total_score = 0;
161             foreach my $score ($self->score_values) {
162             $total_score += $score->[0];
163             }
164            
165             return $total_score;
166             }
167              
168             #
169             # RULES
170             #
171              
172             sub _rule_npa_eq_nxx {
173             return $_[0]->npa eq $_[0]->nxx ? 1 : 0;
174             }
175              
176             sub _rule_sub1_eq_sub2 {
177             return $_[0]->sub1 eq $_[0]->sub2 ? 1 : 0;
178             }
179              
180             sub _rule_nxx_repeats {
181             return $_[0]->_do_digits_repeat($_[0]->nxx) ? 2 : 0;
182             }
183              
184             sub _rule_sub_repeats {
185             return $_[0]->_do_digits_repeat($_[0]->sub) ? 3 : 0;
186             }
187              
188             sub _rule_nxx_sub_repeats {
189             return $_[0]->_do_digits_repeat($_[0]->nxx_sub) ? 5 : 0;
190             }
191              
192             sub _rule_sub1_repeats {
193             return $_[0]->_do_digits_repeat($_[0]->sub1) ? 1 : 0;
194             }
195              
196             sub _rule_sub2_repeats {
197             return $_[0]->_do_digits_repeat($_[0]->sub2) ? 1 : 0;
198             }
199              
200             sub _rule_nxx_sub_is_sequential_asc {
201             return $_[0]->_is_sequential_asc($_[0]->nxx_sub) ? 3 : 0;
202             }
203              
204             sub _rule_nxx_sub_is_sequential_desc {
205             return $_[0]->_is_sequential_desc($_[0]->nxx_sub) ? 2 : 0;
206             }
207              
208             sub _rule_sub_is_sequential_asc {
209             return $_[0]->_is_sequential_asc($_[0]->sub) ? 1 : 0;
210             }
211              
212             sub _rule_sub_is_sequential_desc {
213             return $_[0]->_is_sequential_desc($_[0]->sub) ? 1 : 0;
214             }
215              
216             sub _rule_last_three_pairs_repeat {
217             return $_[0]->nxx_sub =~ m|(\d{2})(\1{2})$| ? 2 : 0;
218             }
219              
220             sub _rule_digit_repeats {
221             my $self = shift;
222              
223             my $num = $self->number;
224             my $score = 0;
225             my @desc;
226              
227             while ($num =~ m|(\d)(\1{2,})|g) {
228             my $digit = $1;
229             my $size = length($2) + 1;
230              
231             # skip if match found inside repeating nxx as it has it's own rule
232             next if
233             $size == 3 &&
234             $digit eq substr($self->nxx, 0 , 1) &&
235             $self->_rule_nxx_repeats;
236            
237             $score += $size - 1;
238             push @desc, loc('Digit [_1] repeats [_2] times for [quant,_3,point].',
239             $digit, $size, $score);
240             }
241            
242             return ($score, join(' ', @desc));
243             }
244              
245             sub _rule_matches_dictionary {
246             my $self = shift;
247            
248             my @dict = @{$self->dictionary};
249             return 0 unless @dict;
250            
251             my %keypad = %{$self->keypad_layout};
252             my $chars = join('', keys %keypad);
253             my $regex = qr/^[$chars]+$/i;
254            
255             my $word;
256             my $score = 0;
257             while ($word = shift @dict) {
258             my $word_length = length($word);
259             next if $word_length > 7;
260             next if $word !~ $regex;
261            
262             my $number = $self->_word_to_digits($word);
263             if (substr($self->nxx_sub, -$word_length) eq $number) {
264             $score = $word_length - 2;
265             }
266            
267             last if $score;
268             }
269            
270             return 0 unless $score;
271             return (
272             $score,
273             loc('Matches word "[_1]" for [quant,_2,point]', uc($word), $score)
274             );
275             }
276              
277             #
278             # RULE HELPERS
279             #
280              
281             sub _do_digits_repeat {
282             return $_[1] =~ m|^(\d)(\1+)$| ? 1 : 0;
283             }
284              
285             sub _is_sequential_asc {
286             return index('01234567890', $_[1]) > -1 ? 1 : 0;
287             }
288              
289             sub _is_sequential_desc {
290             return index('09876543210', $_[1]) > -1 ? 1 : 0;
291             }
292              
293             sub _word_to_digits {
294             my $self = shift;
295             my $word = uc(shift);
296             my %keypad = %{$self->keypad_layout};
297             return join('', map { $keypad{$_} } split(//, $word));
298             }
299              
300             =head1 NAME
301              
302             Number::Phone::NANP::Vanity - Calculate vanity score of a NANP phone number
303              
304             =head1 VERSION
305              
306             0.04
307              
308             =head1 SYNOPSIS
309              
310             use Number::Phone::NANP::Vanity;
311            
312             # simple case
313             my $number = Number::Phone::NANP::Vanity->new(number => '8005551234');
314             my $score = $number->calculate_score;
315            
316             # check against a list of words as well
317             my $number = Number::Phone::NANP::Vanity->new(
318             number => '8005551234',
319             dictionary => [qw/flowers florist roses/],
320             );
321             my $score = $number->calculate_score;
322            
323             # parses formatted numbers too
324             my $number = Number::Phone::NANP::Vanity->from_string('+1-800-555-1234');
325            
326             # print formatted number
327             print $number->number_formatted; # 800-555-1234
328            
329             # custom format
330             print $number->number_formatted('(%s) %s-%s%s'); # (800) 555-1234
331              
332             =head1 METHODS
333              
334             =head2 new(%options)
335              
336             =over 4
337              
338             =item C<number>: a full, clean, 10 digit number
339              
340             =item C<dictionary>: pass a reference to array containing a list of words you'd
341             like to check the number against. I<(optional)>
342              
343             =item C<keypad_layout>: pass a reference to hash containing an alternative
344             keypad mapping. By default it uses International Standard layout. I<(optional)>
345              
346             =back
347              
348             =head2 calculate_score()
349              
350             Calculates and returns an integer score for the given number.
351              
352             =head2 number_formatted($format)
353              
354             Returns the number in a provided format. The format is the same as sprintf.
355             Default format is "%s-%s-%s%s" (800-555-1234).
356              
357             =head1 RULES
358              
359             First of all, some terminology:
360              
361             =over 4
362              
363             =item B<NPA>: area code, first 3 digits
364              
365             =item B<NXX>: exchange, the 3 digits following NPA
366              
367             =item B<sub>: subscriber part of the number, the last 4 digits
368              
369             =item B<sub1>: first 2 digits of the subscriber part
370              
371             =item B<sub2>: last 2 digits of the subscriber part
372              
373             =back
374              
375             =head2 npa_eq_nxx
376              
377             NPA (area code) portion of the number equals NXX (exchange) portion of the
378             number.
379              
380             E.g. B<800-800>-1234.
381              
382             Gets 1 point.
383              
384             =head2 sub1_eq_sub2
385              
386             Subscriber parts repeat.
387              
388             E.g. 800-745-B<1212>
389              
390             Gets 1 point.
391              
392             =head2 nxx_repeats
393              
394             NXX portion has all repeating numbers.
395              
396             E.g. 800-B<555>-5678
397              
398             Gets 2 points.
399              
400             =head2 sub_repeats
401              
402             Subscriber portion has all repeating numbers.
403              
404             E.g. 800-478-B<5555>
405              
406             Gets 3 points.
407              
408             =head2 nxx_sub_repeats
409              
410             Both NXX and subscriber portions repeat.
411              
412             E.g. 800-B<555>-B<5555>
413              
414             Gets 5 points.
415              
416             =head2 sub1_repeats
417              
418             Sub1 has repeating numbers.
419              
420             E.g. 800-478-B<22>32
421              
422             Gets 1 point.
423              
424             =head2 sub2_repeats
425              
426             Sub2 has repeating numbers.
427              
428             E.g. 800-478-32B<22>
429              
430             Gets 1 point.
431              
432             =head2 nxx_sub_is_sequential_asc
433              
434             NXX and subscriber follow an ascending sequential number pattern.
435              
436             E.g. 800-B<234>-B<5678>
437              
438             Gets 3 points.
439              
440             =head2 nxx_sub_is_sequential_desc
441              
442             NXX and subscriber follow a descending sequential number pattern.
443              
444             E.g. 800-B<765>-B<4321>
445              
446             Gets 2 points.
447              
448             =head2 sub_is_sequential_asc
449              
450             Subscriber follows an ascending sequential number pattern.
451              
452             E.g. 800-478-B<1234>
453              
454             Gets 1 point.
455              
456             =head2 sub_is_sequential_desc
457              
458             Subscriber follows a descending sequential number pattern.
459              
460             E.g. 800-478-B<4321>
461              
462             Gets 1 point. I'd give it half, but don't want to get into decimals.
463              
464             =head2 last_three_pairs_repeat
465              
466             The last 3 pairs of digits repeat.
467              
468             E.g. 800-5-B<121212>
469              
470             Gets 2 points.
471              
472             =head2 digit_repeats
473              
474             Checks the entire number for 3 or more consequitive repeating digits.
475              
476             E.g. 800-22B<7-777>1
477              
478             There are 4 consequitive digits 7.
479              
480             Gets 1 point for each repetition over 2 digits long. E.g. 1 point for 3 digits,
481             2 points for 4 digits.
482              
483             =head2 matches_dictionary
484              
485             The number matches a word provided via dictionary attribute. The words are
486             checked in the order provided.
487              
488             Score will be recorded upon first successful match. No further matching will be
489             performed.
490              
491             Matching is performed against the tail part of the word only.
492              
493             Words with more than 7 letters are skipped.
494              
495             Words with characters not contained in the keypad_layout are skipped.
496              
497             Score is assigned based on the length of the word matched. One point is assigned
498             for every letter matched above, and including a 3 character word. E.g.:
499              
500             800-555-2FUN - 1 point (3 letter word matches)
501              
502             800-555-PERL - 2 points (4 letter word matches)
503              
504             800-55-LLAMA - 3 points (5 letter word matches)
505              
506             =head1 CUSTOM RULES
507              
508             You can also define your own custom rules by passing an anonymous sub to the
509             C<add_rule> method. The sub must return a score (int) equal or greater than
510             zero. An optional second parameter can be returned as a string describing why
511             the score was assigned.
512              
513             my $number = Number::Phone::NANP::Vanity->new(number => '8003141592');
514             $number->add_rule(sub {
515             return (10, "Toll Free Pi")
516             if shift->number eq '8003141592';
517             });
518             my $score = $number->calculate_score;
519              
520             =head1 EXTENDING
521              
522             Traits?
523              
524             =head1 CAVEATS
525              
526             Due to the fluid nature of this module, the rules might be changed at any time.
527             New rules might be added later on. Therefore you should not rely on the score
528             being fair across multiple sessions. The score should be used to compare the
529             number vanity during one session run. In other words, the score shall not be
530             recorded and compared against in the future.
531              
532             =head1 AUTHOR
533              
534             Roman F. <romanf@cpan.org>
535              
536             =head1 COPYRIGHT AND LICENSE
537              
538             This software is copyright (c) 2011 by Roman F.
539              
540             This is free software; you can redistribute it and/or modify it under the
541             same terms as the Perl 5 programming language system itself.
542              
543             =cut
544              
545             __PACKAGE__->meta->make_immutable;
546             no Moose;