File Coverage

File:blib/lib/Geo/Coder/Free.pm
Coverage:79.2%

linestmtbrancondsubpodtimecode
1package Geo::Coder::Free;
2
3
15
15
15
1504234
14
157
use strict;
4
15
15
15
21
12
342
use warnings;
5
15
15
15
2302
70491
34
use autodie qw(:all);
6
7
15
15
15
92212
18
362
use Carp;
8
15
15
15
3478
35603
248
use Config::Auto;
9
15
15
15
3124
34
328
use Geo::Coder::Free::Local;
10
15
15
15
3147
42
370
use Geo::Coder::Free::MaxMind;
11
15
15
15
3325
31
387
use Geo::Coder::Free::OpenAddresses;
12
15
15
15
41
11
397
use Geo::Coder::Free::Utils qw(_abbreviate _normalize);
13
15
15
15
31
12
148
use Object::Configure;
14
15
15
15
19
13
186
use Params::Get;
15
15
15
15
25
13
260
use Readonly;
16
15
15
15
29
12
26682
use Scalar::Util;
17
18 - 26
=head1 NAME

Geo::Coder::Free - Geocoding using free, locally-hosted databases

=head1 VERSION

Version 0.43

=cut
27
28our $VERSION = '0.43';
29
30=encoding utf-8
31
32 - 114
=head1 SYNOPSIS

    use Geo::Coder::Free;

    my $geo = Geo::Coder::Free->new();
    my $pt  = $geo->geocode(location => 'Ramsgate, Kent, UK');
    printf "%.6f, %.6f\n", $pt->lat(), $pt->long();

    # With OpenAddresses/WhoOnFirst data:
    my $geo2 = Geo::Coder::Free->new(openaddr => $ENV{OPENADDR_HOME});
    my $pt2  = $geo2->geocode(location => '1600 Pennsylvania Avenue NW, Washington DC, USA');

    # Free-text scanning:
    my @hits = $geo2->geocode(scantext => 'She grew up in Ramsgate, Kent.',
                              region   => 'GB');

=head1 DESCRIPTION

C<Geo::Coder::Free> translates addresses into latitude/longitude coordinates
using local SQLite databases built from free data sources - MaxMind/GeoNames,
OpenAddresses, Who's On First, OpenStreetMap, and dr5hn's countries/states/cities
database.  It deliberately avoids paid or rate-limited online geocoding services.
The module is designed to be flexible, supporting both command-line and programmatic usage.
It also includes a sample CGI script for a web-based geocoding service.

Geocoding dispatch order depends on whether C<OPENADDR_HOME> (or C<openaddr>) is set:

B<With OpenAddresses data:>

=over 4

=item 1. C<Geo::Coder::Free::OpenAddresses> - requires C<OPENADDR_HOME>

=item 2. C<Geo::Coder::Free::Local> - user-curated CSV entries (tried as fallback)

=item 3. C<Geo::Coder::Free::MaxMind> - bundled, always available

=back

B<Without OpenAddresses data:>

=over 4

=item 1. C<Geo::Coder::Free::MaxMind> only - Local is not consulted.

=back

The C<cgi-bin> directory contains a simple DIY geo-coding website:

    cgi-bin/page.fcgi page=query q=1600+Pennsylvania+Avenue+NW+Washington+DC+USA

The sample website is currently down while a new host is sought.
When it returns, you will be able to test it with:

    curl 'https://geocode.nigelhorne.com/cgi-bin/page.fcgi?page=query&q=1600+Pennsylvania+Avenue+NW+Washington+DC+USA'

=head1 LIMITATIONS

=over 4

=item * C<scantext> mode only finds locations in OpenAddresses; it falls back
silently when C<OPENADDR_HOME> is not set (B<FIXME>: should warn).

=item * The C<__DATA__> alternatives table is hard-coded; it should live in a
user-editable config file.

=item * The address-regex scantext path misses birth-year sentences such as
C<"She was born May 21, 1937 in Noblesville, IN."> because the regex requires
a preceding capital-letter word directly before the city.

=item * C<reverse_geocode> is only partially implemented; the MaxMind path does
not return meaningful results.

=item * The C<alternatives> map loop uses C<each %{$alt}>, which retains its
iterator position across calls.  After a successful match and early C<return>,
the next C<geocode()> call on the same input starts iterating from the key
B<after> the matched one, potentially missing the match entirely until C<each>
wraps around.  Workaround: call C<keys %{$alt}> once to reset the iterator
before iterating.

=back

=cut
115
116# -----------------------------------------------------------------------
117# Module-level singletons — initialised once, shared across all instances.
118# Using 'our' so that test code can reset them between test runs if needed.
119# -----------------------------------------------------------------------
120our $alternatives;
121
122# -----------------------------------------------------------------------
123# Error-message table.  All user-facing strings live here so that a future
124# i18n layer only needs to swap this hash, not touch every call site.
125# -----------------------------------------------------------------------
126my %_MESSAGES = (
127        usage_geocode       => 'Usage: %s::geocode(location => $location|scantext => $text)',
128        usage_reverse       => 'Usage: %s::reverse_geocode(latlng => "$lat,$long")',
129        invalid_location    => '%s: invalid location to geocode(), %s',
130        invalid_scantext    => '%s: invalid scantext to geocode(), %s',
131        geocoding_failed    => '%s: geocoding failed',
132        reverse_unsupported => 'Reverse lookup is not yet supported',
133        use_arrow_new       => '%s: use ->new() not ::new() to instantiate',
134        bad_ref_arg         => 'Usage: %s — do not pass a non-hash reference',
135);
136
137# Confidence thresholds for scantext results, expressed as named constants
138# rather than magic numbers so call sites document intent.
139Readonly::Scalar my $CONF_TRIPLET => 0.8;
140Readonly::Scalar my $CONF_DUPLET  => 0.7;
141Readonly::Scalar my $CONF_REGEX   => 0.7;
142
143# Stopwords excluded from word-window scantext searches.
144my %_COMMON_WORDS = map { $_ => 1 } qw(
145        a age an and at be by cross for how i in is more of on or over pm
146        road she side some the to was with
147);
148
149 - 198
=head1 METHODS

=head2 new

=head3 SYNOPSIS

    my $geo = Geo::Coder::Free->new();
    my $geo = Geo::Coder::Free->new(openaddr => '/data/openaddr');
    my $geo = Geo::Coder::Free->new(directory => '/data/maxmind');

=head3 DESCRIPTION

Constructor.  Accepts a hash or hashref of options.  If called without
C<openaddr>, the module checks C<$ENV{OPENADDR_HOME}> before giving up.

If called on an existing object instance (C<< $clone = $geo->new() >>), returns
a B<shallow clone>.  All scalar fields are copied by value, but reference-type
fields (C<alternatives>, C<scantext_misses>, C<maxmind>, C<openaddr>) share the
same underlying object or hashref between the original and the clone.  Mutations
to those shared references are immediately visible in both objects.

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict)
    openaddr  => { type => 'scalar', optional => 1 }                          # path to OpenAddresses/WOF data dir
    directory => { type => 'scalar', optional => 1 }                          # path to MaxMind/GeoNames files
    cache     => { type => 'object', optional => 1, can => ['get', 'set'] }   # CHI-compatible cache object

=head4 output

    # Output schema (Return::Set)
    { type => 'object', isa => 'Geo::Coder::Free' }

=head3 EXAMPLE

    use Geo::Coder::Free;

    # Minimal - uses only the bundled MaxMind data:
    my $geo = Geo::Coder::Free->new();

    # Full - also searches OpenAddresses/WOF:
    my $geo = Geo::Coder::Free->new(openaddr => $ENV{OPENADDR_HOME});

=head3 MESSAGES

    use ->new() not ::new()   Called as a function; use arrow syntax.

=cut
199
200sub new {
201
107
1
641265
        my $class = shift;
202
203
107
207
        my $params = Params::Get::get_params(undef, @_) // {};
204
205        # Called as a function (Geo::Coder::Free::new) rather than a method
206
107
1487
        if (!defined($class)) {
207
5
5
6
11
                if (keys %{$params}) {
208
2
5
                        Carp::carp(_i18n('use_arrow_new', __PACKAGE__));
209
2
227
                        return;
210                }
211
3
5
                $class = __PACKAGE__;   # FIXME: only works with no arguments
212        } elsif (Scalar::Util::blessed($class)) {
213
7
7
7
8
15
29
                return bless { %{$class}, %{$params} }, ref($class);
214        }
215
216        # Populate the alternatives map once from __DATA__ and cache it
217        # across all instances.  Config::Auto parses the INI-like DATA block.
218
98
170
        if (!$alternatives) {
219
9
18
                my $keep = $/;
220
9
21
                local $/ = undef;
221
9
98
                my $data = <DATA>;
222
9
12
                $/ = $keep;
223
224
9
66
                $alternatives = Config::Auto->new(source => $data)->parse();
225                # Config::Auto turns multi-value keys into arrayrefs; flatten them.
226
9
45
17662
993
                while (my ($key, $value) = each %{$alternatives}) {
227
36
36
22
56
                        $alternatives->{$key} = join(', ', @{$value});
228                }
229        }
230
231        # Resolve OPENADDR_HOME before Object::Configure so plug-ins can see it
232
98
374
        if (!defined($params->{'openaddr'}) && $ENV{'OPENADDR_HOME'}) {
233
16
26
                $params->{'openaddr'} = $ENV{'OPENADDR_HOME'};
234        }
235
236
98
231
        $params = Object::Configure::configure($class, $params);
237
238        my $rc = {
239
98
98
445048
524
                %{$params},
240                maxmind      => Geo::Coder::Free::MaxMind->new($params),
241                alternatives => $alternatives,
242        };
243
244
98
37314
        if ($params->{'openaddr'}) {
245
27
27
27
143
                $rc->{'openaddr'} = Geo::Coder::Free::OpenAddresses->new(id => 'md5', %{$params});
246        }
247
96
212
        if (my $cache = $params->{'cache'}) {
248
8
12
                $rc->{'cache'} = $cache;
249        }
250
251
96
377
        return bless $rc, $class;
252}
253
254 - 317
=head2 geocode

=head3 SYNOPSIS

    # Standard lookup (returns a Geo::Location::Point or undef)
    my $pt = $geo->geocode(location => 'Ramsgate, Kent, UK');
    printf "lat=%.6f lon=%.6f\n", $pt->lat(), $pt->long();

    # Scantext - returns a list of Geo::Location::Point objects
    my @hits = $geo->geocode(
        scantext     => 'She lived in Ramsgate, Kent.',
        region       => 'GB',
        ignore_words => [qw(lived)],
    );

    # Invocation flexibility (all equivalent)
    $geo->geocode('Ramsgate, Kent, UK');
    $geo->geocode({ location => 'Ramsgate, Kent, UK' });
    $geo->geocode(location => 'Ramsgate, Kent, UK');

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict) - exactly one of location or scantext is required
    location     => { type => 'scalar',   optional => 1 }  # address string (exclusive with scantext)
    scantext     => { type => 'scalar',   optional => 1 }  # free text to scan for place names
    region       => { type => 'scalar',   optional => 1 }  # ISO 3166-1 alpha-2 country code hint
    ignore_words => { type => 'arrayref', optional => 1 }  # words to suppress during scantext scan

=head4 output

    # Output schema (Return::Set)
    # scalar context: { type => 'object',   isa => 'Geo::Location::Point', optional => 1 }
    # list context:   { type => 'arrayref', of  => { isa => 'Geo::Location::Point' } }

=head3 MESSAGES

    Usage: ...::geocode(...)        No location or scantext argument given.
    invalid location to geocode()   location is purely numeric.
    invalid scantext to geocode()   scantext is purely numeric.

=head3 PSEUDOCODE

    if self is not a blessed object → delegate to new()->geocode(@args)
    normalise @_ into %params
    validate: location is not purely numeric; scantext is not purely numeric
    if openaddr backend is available:
        if scantext:
            try the raw scantext string as a direct location
            build stopword set from %_COMMON_WORDS + ignore_words param
            try 3-word windows (triplets) at confidence 0.8
            try 2-word windows (duplets) at confidence 0.7
            try the address-pattern regex at confidence 0.7
            try region-specific address finders (GB / US / CA)
            mark scantext as a miss; return undef
        else:
            try openaddr backend
            try local backend
            try __DATA__ alternatives map
    try maxmind backend for location lookups
    croak if no scantext and no location

=cut
318
319sub geocode {
320
103
1
37951
        my $self = shift;
321
322        # Handle being called as a function rather than a method
323
103
220
        if (!ref($self)) {
324
8
22
                if (scalar @_) {
325
1
3
                        return __PACKAGE__->new()->geocode(@_);
326                } elsif (!defined($self)) {
327
1
2
                        Carp::croak(_i18n('usage_geocode', __PACKAGE__));
328                } elsif ($self eq __PACKAGE__) {
329
5
9
                        Carp::croak(_i18n('usage_geocode', $self));
330                }
331
1
3
                return __PACKAGE__->new()->geocode($self);
332        } elsif (ref($self) eq 'HASH') {
333
1
3
                return __PACKAGE__->new()->geocode($self);
334        }
335
336
94
147
        my %params = _normalize_args(_i18n('usage_geocode', __PACKAGE__), 'location', @_);
337
338        # Reject pure-numeric inputs early — they are never valid addresses
339
84
246
        if (defined($params{'location'}) && $params{'location'} !~ /\D/) {
340                Carp::croak(_i18n('invalid_location', __PACKAGE__, $params{'location'}))
341
15
36
                        if length($params{'location'});
342
3
12
                return;
343        }
344
69
142
        if (defined($params{'scantext'}) && $params{'scantext'} !~ /\D/) {
345                Carp::croak(_i18n('invalid_scantext', __PACKAGE__, $params{'scantext'}))
346
6
14
                        if length($params{'scantext'});
347
3
8
                return;
348        }
349
350
63
152
        if ($self->{'openaddr'}) {
351
31
41
                if (my $scantext = $params{'scantext'}) {
352
11
21
                        return if $self->{'scantext_misses'}{$scantext};
353
354                        # First try the whole scantext as a direct location lookup —
355                        # saves work when the text happens to be a valid address string.
356
7
34
                        $self->{'local'} ||= Geo::Coder::Free::Local->new();
357
14
36
                        my @direct = grep { defined }
358                                $self->{'local'}->geocode($scantext),
359                                $self->{'openaddr'}->geocode($scantext),
360
7
38
                                $self->{'maxmind'}->geocode($scantext);
361
7
11
                        return @direct if @direct;
362
363
7
5
                        my $region = $params{'region'};
364
365                        # Build the effective stopword set.  When the caller supplies no
366                        # ignore_words we avoid copying %_COMMON_WORDS (which is immutable
367                        # at runtime) by using a reference to it directly.  Only when extra
368                        # words are needed do we allocate and populate a merged hash.
369
7
7
                        my $ignore_words;
370
7
12
                        if (my $iw = $params{'ignore_words'}) {
371
1
12
                                my %merged = %_COMMON_WORDS;
372
1
1
2
3
                                $merged{lc $_} = 1 for @{$iw};
373
1
2
                                $ignore_words = \%merged;
374                        } else {
375
6
6
                                $ignore_words = \%_COMMON_WORDS;
376                        }
377
378
7
5
                        my @rc;
379
380                        # 3-word window search
381
7
15
                        my @triplets = _find_word_ngrams($scantext, 3, $ignore_words);
382
7
17
                        my $res = $self->_resolve_scan_candidates(\@triplets, $region, $CONF_TRIPLET, $scantext);
383
7
7
6
11
                        if (@{$res}) {
384
0
0
0
0
                                return wantarray ? @{$res} : $res->[0];
385                        }
386
387                        # 2-word window search
388
7
10
                        my @duplets = _find_word_ngrams($scantext, 2, $ignore_words);
389
7
15
                        $res = $self->_resolve_scan_candidates(\@duplets, $region, $CONF_DUPLET, $scantext);
390
7
7
5
10
                        if (@{$res}) {
391
0
0
0
0
                                return wantarray ? @{$res} : $res->[0];
392                        }
393
394                        # Regex-based address pattern — catches "City, ST"-style fragments.
395                        # Note: misses sentences like "born May 21, 1937 in Noblesville, IN"
396                        # because the pattern requires a capitalised word before the city.
397
7
13
                        my @regex_matches = $scantext =~
398                                /\b(?:\d+\s+)?(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\.?),\s*
399                                 (?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*(?:,\s*[A-Z]{2,})*)\b/gx;
400
7
0
6
0
                        my @places = grep { defined && $_ ne '' } @regex_matches;
401
7
11
                        $res = $self->_resolve_scan_candidates(\@places, $region, $CONF_REGEX, $scantext);
402
7
7
6
7
                        if (@{$res}) {
403
0
0
0
0
                                return wantarray ? @{$res} : $res->[0];
404                        }
405
406                        # Region-specific structured address patterns
407
7
17
                        if ($region) {
408
0
0
                                my @candidates;
409
0
0
0
0
                                if    ($region eq 'GB') { @candidates = _find_gb_addresses($scantext) }
410
0
0
                                elsif ($region eq 'US') { @candidates = _find_us_addresses($scantext) }
411
0
0
                                elsif ($region eq 'Canada') { @candidates = _find_ca_addresses($scantext) }
412
413
0
0
                                if (@candidates) {
414
0
0
                                        my @regional;
415
0
0
                                        for my $candidate (@candidates) {
416
0
0
                                                next if $ignore_words->{lc $candidate};
417
0
0
                                                my @hits = grep { defined }
418
0
0
                                                        $self->{'openaddr'}->geocode("$candidate, $region");
419
0
0
                                                push @regional, @hits if @hits;
420                                        }
421
0
0
                                        return @regional if @regional;
422                                }
423                        }
424
425
7
10
                        $self->{'scantext_misses'}{$scantext} = 1;
426
7
31
                        return;
427                }
428
429                # Standard (non-scantext) lookup path
430
20
29
                if (wantarray) {
431
1
3
                        my @rc = $self->{'openaddr'}->geocode(\%params);
432
1
6
                        return @rc if @rc && $rc[0];
433
0
0
                        $self->{'local'} ||= Geo::Coder::Free::Local->new();
434
0
0
                        @rc = $self->{'local'}->geocode(\%params);
435
0
0
                        return @rc if @rc && $rc[0];
436                } else {
437
19
44
                        if (my $rc = $self->{'openaddr'}->geocode(\%params)) {
438
7
86
                                return $rc;
439                        }
440
10
68
                        $self->{'local'} ||= Geo::Coder::Free::Local->new();
441
10
64
                        if (my $rc = $self->{'local'}->geocode(\%params)) {
442
3
13
                                return $rc;
443                        }
444                }
445
446                # Try the alternatives table — hand-curated mappings for locations that
447                # the databases have under slightly different names.
448                # M3 (transitive reduction): every scantext path inside this block returned
449                # early above; at this point $params{'scantext'} is provably falsy, so the
450                # former `if (!$params{'scantext'})` guard was a tautology — removed.
451
7
14
                if (my $alt = $self->{'alternatives'}) {
452
7
11
                        my $location = $params{'location'};
453
7
27
6
45
                        while (my ($key, $value) = each %{$alt}) {
454
22
75
                                next unless $location =~ $key;
455
2
8
                                (my $new_loc = $location) =~ s/$key/$value/;
456
2
4
                                $params{'location'} = $new_loc;
457
2
15
                                if (my $rc = $self->geocode(\%params)) {
458
2
8
                                        return $rc;
459                                }
460                                # Also try the comma-free variant ("Tyne and Wear" etc.)
461
0
0
                                if ($value =~ /, /) {
462
0
0
                                        (my $flat = $value) =~ s/,//g;
463
0
0
                                        ($new_loc = $location) =~ s/$key/$flat/;
464
0
0
                                        $params{'location'} = $new_loc;
465
0
0
                                        if (my $rc = $self->geocode(\%params)) {
466
0
0
                                                return $rc;
467                                        }
468                                }
469                                # Restore for the next iteration
470
0
0
                                $params{'location'} = $location;
471                        }
472                }
473        }
474
475        # Final fallback: MaxMind for location lookups.
476        # Scantext without OPENADDR_HOME will silently reach here and return undef;
477        # a proper fix would warn here (see LIMITATIONS).
478        # M8 (tautology elimination): both arms of the former wantarray ternary were
479        # identical — collapsed to a single call; context propagates implicitly.
480
37
62
        if ($params{'location'}) {
481
28
67
                return $self->{'maxmind'}->geocode(\%params);
482        }
483
484
9
21
        Carp::croak(_i18n('usage_geocode', __PACKAGE__)) unless $params{'scantext'};
485
3
6
        return;
486}
487
488 - 516
=head2 reverse_geocode

=head3 SYNOPSIS

    my $loc = $geo->reverse_geocode(latlng => '51.3341,-1.4159');

=head3 DESCRIPTION

Translates a latitude/longitude pair back to a place name.
B<Partially implemented>: the MaxMind backend does not return meaningful results.
OpenAddresses is attempted first when available.

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict) — latlng required
    latlng => { type => 'scalar' }  # "$lat,$long" comma-separated decimal degrees
    # NOTE: separate lat/lon/long keys are NOT supported at the Geo::Coder::Free
    # (facade) level.  When no OpenAddresses backend is configured, passing
    # lat/lon/long instead of latlng will croak "not yet supported".
    # To use separate coordinates call Geo::Coder::Free::Local::reverse_geocode.

=head4 output

    # Output schema (Return::Set)
    { type => 'object', isa => 'Geo::Location::Point', optional => 1 }

=cut
517
518sub reverse_geocode {
519
21
1
8541
        my $self = shift;
520
521
21
45
        if (!ref($self)) {
522
5
11
                if (scalar @_) {
523
1
2
                        return __PACKAGE__->new()->reverse_geocode(@_);
524                } elsif (!defined($self)) {
525
2
4
                        Carp::croak(_i18n('usage_reverse', __PACKAGE__));
526                } elsif ($self eq __PACKAGE__) {
527
1
1
                        Carp::croak(_i18n('usage_reverse', $self));
528                }
529
1
4
                return __PACKAGE__->new()->reverse_geocode($self);
530        } elsif (ref($self) eq 'HASH') {
531
0
0
                return __PACKAGE__->new()->reverse_geocode($self);
532        }
533
534
16
30
        my %params = _normalize_args(_i18n('usage_reverse', __PACKAGE__), 'latlng', @_);
535
536        # M8 (tautology — same pattern fixed in geocode): both arms are identical;
537        # context propagates implicitly through return.
538
14
31
        if ($self->{'openaddr'}) {
539
0
0
                return $self->{'openaddr'}->reverse_geocode(\%params);
540        }
541
14
27
        if ($params{'latlng'}) {
542
5
14
                return $self->{'maxmind'}->reverse_geocode(\%params);
543        }
544
545
9
15
        Carp::croak(_i18n('reverse_unsupported'));
546}
547
548 - 552
=head2 ua

Does nothing.  Present for drop-in compatibility with other Geo::Coder::* modules.

=cut
553
554
1
sub ua { }
555
556 - 562
=head2 run

Command-line entry point.  Use as:

    perl lib/Geo/Coder/Free.pm 1600 Pennsylvania Avenue NW, Washington DC

=cut
563
564__PACKAGE__->run(@ARGV) unless caller();
565
566sub run {
567
0
1
0
        require Data::Dumper;
568
569
0
0
        my $class    = shift;
570
0
0
        my $location = join ' ', @_;
571
572        my @rc = $ENV{'OPENADDR_HOME'}
573
0
0
                ? $class->new(openaddr => $ENV{'OPENADDR_HOME'})->geocode($location)
574                : $class->new()->geocode($location);
575
576
0
0
        Carp::croak(_i18n('geocoding_failed', $0)) unless @rc;
577
578
0
0
        print Data::Dumper->new([\@rc])->Dump();
579}
580
581# -----------------------------------------------------------------------
582# Private helpers
583# -----------------------------------------------------------------------
584
585# Purpose:  Return a formatted message string from the message table.
586#           Falls back to the raw key so call sites never die on a missing key.
587# Entry:    $key — message key; @args — sprintf format arguments.
588# Exit:     Formatted string.
589sub _i18n {
590
155
138120
        my ($key, @args) = @_;
591
155
257
        my $fmt = $_MESSAGES{$key} // $key;
592
155
834
        return @args ? sprintf($fmt, @args) : $fmt;
593}
594
595# Purpose:  Normalise the four calling conventions accepted by geocode/reverse_geocode:
596#             hashref, even-length key-val list, odd-length list, single bare string.
597# Entry:    $error_msg — message to croak if a non-hash reference is passed.
598#           $bare_key  â€” hash key to assign to a single bare string argument
599#                        ('location' for geocode, 'latlng' for reverse_geocode).
600#           @args — raw @_ after $self has been shifted.
601# Exit:     Flat key-value %params hash.
602# Side Effects: None; croaks on unsupported reference argument.
603sub _normalize_args {
604
120
129653
        my ($error_msg, $bare_key, @args) = @_;
605
120
7
187
16
        return %{$args[0]}              if ref($args[0]) eq 'HASH';
606
113
199
        Carp::croak($error_msg)         if ref($args[0]);
607
105
328
        return @args                    if @args && @args % 2 == 0;
608
30
54
        return ($bare_key => $args[0])  if @args == 1;
609        # M7 (logic-gap closure): odd-count > 1 is not a valid calling convention.
610        # The former code silently returned () here, discarding the trailing elements.
611        # Fail fast instead of propagating a malformed argument list downstream.
612
18
101
        Carp::croak($error_msg)         if @args;
613
11
18
        return ();
614}
615
616# Purpose:  Try to geocode each candidate place string via the openaddr backend,
617#           annotating every hit with location/text/confidence metadata and
618#           memoising misses to avoid re-querying the same failing string.
619# Entry:    $self       â€” geocoder instance with {openaddr} and {scantext_misses}.
620#           $candidates — arrayref of place-name strings to probe.
621#           $region     â€” optional ISO country code appended to each candidate.
622#           $confidence — numeric confidence score assigned to all hits.
623#           $scantext   â€” original free text (stored inside each result object).
624# Exit:     Arrayref of Geo::Location::Point objects; empty arrayref on no hits.
625# Side Effects: Populates $self->{scantext_misses} for every non-matching place.
626sub _resolve_scan_candidates {
627
25
3252
        my ($self, $candidates, $region, $confidence, $scantext) = @_;
628
25
13
        my @results;
629
25
25
17
20
        for my $place (@{$candidates}) {
630
33
28
                my $location = $region ? "$place, $region" : $place;
631
33
39
                next if $self->{'scantext_misses'}{$location};
632
32
30
36
84
                my @res = grep { defined } $self->{'openaddr'}->geocode($location);
633
32
26
                if (@res) {
634
1
2
                        for my $entry (@res) {
635
1
20
                                $entry->{'location'}   = $location;
636
1
2
                                $entry->{'text'}       = $scantext;
637
1
1
                                $entry->{'confidence'} = $confidence;
638                        }
639
1
2
                        push @results, @res;
640                } else {
641
31
52
                        $self->{'scantext_misses'}{$location} = 1;
642                }
643        }
644
25
22
        return \@results;
645}
646
647# Purpose:  Slide a window of $n words across $text and return all comma-joined
648#           n-grams, excluding pure-numeric tokens and stopwords.
649#           Replaces the former _find_word_triplets ($n=3) and
650#           _find_word_duplets ($n=2) which were identical except for window size
651#           and the duplets version was missing the lc() normalisation on stopwords.
652# Entry:    $text — raw string; $n — window size; $stop — stopword hashref.
653# Exit:     Flat list of n-gram strings.
654sub _find_word_ngrams {
655
33
14483
        my ($text, $n, $stop) = @_;
656
33
36
        $text =~ s/,+/ /g;
657
33
85
        $text =~ s/\s+/ /g;
658
33
99
        $text =~ s/^\s+|\s+$//g;
659
33
119
43
241
        my @words = grep { !/^\d+$/ && !$stop->{lc $_} } split /\s+/, $text;
660
33
26
        my @ngrams;
661
33
69
        for my $i (0 .. $#words - ($n - 1)) {
662
51
109
                push @ngrams, join ', ', @words[$i .. $i + $n - 1];
663        }
664
33
57
        return @ngrams;
665}
666
667# Purpose:  Extract structured US street addresses from free text.
668# Entry:    $text — arbitrary string.
669# Exit:     List of full address strings matching the US pattern.
670sub _find_us_addresses {
671
4
2642
        my $text = shift;
672
4
2
        my @addresses;
673
4
5
        my $re = qr/
674                \b \d{1,5} \s+                                      # house number
675                (?:[A-Za-z0-9]+(?:\s+[A-Za-z0-9]+){0,6}) \s+       # street name: 1–7 words (bounded)
676                (?:Avenue|Ave\.?|Boulevard|Blvd\.?|Road|Rd\.?|Lane|Ln\.?|Drive|Dr\.?|Street|St\.?)
677                (?:\s+[A-Za-z]{2})? ,\s*
678                (?:[A-Za-z]+(?:\s+[A-Za-z]+){0,3}) ,\s*            # city: 1–4 words
679                [A-Z]{2} \s* (?:\d{5}(?:-\d{4})?)? \b              # state + optional zip
680        /x;
681
4
31
        while ($text =~ /$re/g) {
682
2
6
                push @addresses, $&;
683        }
684
4
9
        return @addresses;
685}
686
687# Purpose:  Extract British-style addresses from free text.
688# Entry:    $text — arbitrary string.
689# Exit:     List of trimmed address strings.
690sub _find_gb_addresses {
691
5
4343
        my $text = shift;
692
5
3
        my @addresses;
693        # ReDoS fix: the former pattern used \s*,?\s* between all parts and
694        # [A-Za-z\s'-]+ groups — with commas optional, the engine must try all
695        # possible splits of a long string among 5 overlapping space-containing
696        # groups, causing exponential backtracking.  Making commas mandatory
697        # eliminates the ambiguity entirely: UK postal addresses always have commas.
698        # All groups are non-capturing (only $& is used).
699
5
7
        my $re = qr/
700                \b
701                (?:\d{1,5} | [\w'-]+)                   # house number or single-word name
702                \s+
703                (?:[\w'-]+(?:\s+[\w'-]+){0,5})           # street name (up to 6 words)
704                \s*,\s*                                   # COMMA REQUIRED — kills the ambiguity
705                (?:[\w'-]+(?:\s+[\w'-]+){0,3})           # town (up to 4 words)
706                \s*,\s*                                   # COMMA REQUIRED
707                (?:[\w'-]+(?:\s+[\w'-]+){0,3})           # county (up to 4 words)
708                \s*,\s*                                   # COMMA REQUIRED
709                (?:[\w'-]+(?:\s+[\w'-]+){0,2})           # country (up to 3 words)
710                \b
711        /x;
712
5
34
        while ($text =~ /$re/g) {
713
1
4
                (my $addr = $&) =~ s/[,\s]+$//;
714
1
3
                push @addresses, $addr;
715        }
716
5
8
        return @addresses;
717}
718
719# Purpose:  Extract Canadian street addresses from free text.
720# Entry:    $text — arbitrary string.
721# Exit:     List of full address strings matching the Canadian pattern.
722sub _find_ca_addresses {
723
3
1757
        my $text = shift;
724
3
2
        my @addresses;
725
3
3
        my $re = qr/
726                \b \d{1,5} \s+                                         # house number
727                (?:[A-Za-z0-9]+(?:\s+[A-Za-z0-9]+){0,6}) \s+          # street name: 1–7 words (bounded)
728                (?:Avenue|Ave\.?|Boulevard|Blvd\.?|Road|Rd\.?|Lane|Ln\.?|Drive|Dr\.?|Street|St\.?|Circle|Crescent|Cres\.?)
729                \s*,\s*
730                (?:[A-Za-z]+(?:\s+[A-Za-z]+){0,3}) \s*,\s*            # city: 1–4 words
731                [A-Z]{2} \s*,?\s*
732                (?:[A-Z]\d[A-Z]\s?\d[A-Z]\d)? \b                      # optional postal code
733        /x;
734
3
16
        while ($text =~ /$re/g) {
735
2
6
                push @addresses, $&;
736        }
737
3
5
        return @addresses;
738}
739
740# _normalize and _abbreviate are imported from Geo::Coder::Free::Utils.
741
742 - 870
=head1 GETTING STARTED

To download, import and set up the local database:
before running C<make>, but after running C<perl Makefile.PL>, follow these instructions.

Optionally set C<OPENADDR_HOME> to point to an empty directory and download the data from
L<http://results.openaddresses.io> into that directory; and
optionally set C<WHOSONFIRST_HOME> to point to an empty directory and download the data using
L<https://github.com/nigelhorne/NJH-Snippets/blob/master/bin/wof-clone>.
The script C<bin/download_databases> (see below) will do those for you.
You do not need to download the MaxMind data — that is downloaded automatically.

You will need to create the database used by C<Geo::Coder::Free>.

Install L<App::csv2sqlite> and L<https://github.com/nigelhorne/NJH-Snippets>.
Run C<bin/create_sqlite> — this converts the MaxMind "cities" database from CSV to SQLite.

To use with MariaDB, set C<MARIADB_SERVER="$hostname;$port"> and
C<MARIADB_USER="$user;$password"> (TODO: username/password should be asked for interactively).
The code will use a database called C<geo_code_free>, which will be dropped and recreated if it exists.
C<$user> needs only DROP, CREATE, SELECT, INSERT, and INDEX privileges on that database.

The following optional steps download and install large databases.
This will take a long time and use a lot of disc space.

=over 4

=item 1

C<mkdir $WHOSONFIRST_HOME; cd $WHOSONFIRST_HOME> then run C<wof-clone> from NJH-Snippets.

This can take a long time because it contains many nested directories, which filesystem drivers
can be slow to navigate (particularly on EXT4 and ZFS).

=item 2

Install L<https://github.com/dr5hn/countries-states-cities-database.git> into C<$DR5HN_HOME>.
This data covers cities only, so it is not used when C<OSM_HOME> is set (OSM is far more
comprehensive).  Only Australia, Canada, and the US are imported, as the UK data is difficult
to parse.

=item 3

Run C<bin/download_databases> — this downloads the Who's On First, OpenAddr, OpenStreetMap,
and dr5hn databases.
OpenStreetMap now uses PBF files, so you will need C<apt install osmium-tool> first.
Check the values of C<OSM_HOME>, C<OPENADDR_HOME>, C<DR5HN_HOME> and C<WHOSONFIRST_HOME>
within that script and adjust them for your setup.
The C<Makefile.PL> file downloads the MaxMind database automatically, as it is not optional.

=item 4

Run C<bin/create_db> — this creates the database used by C<Geo::Coder::Free> from the data you
have just downloaded.
The database is called C<openaddr.sql> for historical reasons (before Who's On First was added);
it actually contains data from all sources above.

=back

Now you are ready to run C<make>.
See the comment at the start of C<createdatabase.PL> for further details.

=head1 MORE INFORMATION

I have written several Perl genealogy programs including
L<gedcom|https://github.com/nigelhorne/gedcom> and
L<ged2site|https://github.com/nigelhorne/ged2site>.
One of the things these do is check the validity of a family tree, including verifying place-names.
Of course places do change names and spelling becomes more consistent over the years, but the vast
majority remain the same — enough to make computerised verification worthwhile.

=head1 BUGS

Some lookups fail.  Please file a bug report at
L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Geo-Coder-Free>.

The MaxMind data contains cities only.
The OpenAddresses data does not cover the whole globe.
C<London, England> cannot be parsed yet.

=head1 SEE ALSO

=over 4

=item * L<Configure an Object at Runtime|Object::Configure>

=item * L<Test Dashboard|https://nigelhorne.github.io/Geo-Coder-Free/coverage/>

=back

L<Geo::Coder::Free::Local>, L<Geo::Coder::Free::MaxMind>,
L<Geo::Coder::Free::OpenAddresses>,
L<https://openaddresses.io/>, L<https://www.maxmind.com/>,
L<https://www.geonames.org/>, L<https://www.whosonfirst.org/>.

=head1 AUTHOR

Nigel Horne C<< <njh@nigelhorne.com> >>

=head1 FORMAL SPECIFICATION

=head2 new

    GeoCoderFreeState ::= ⟨⟨ maxmind     : MaxMind_Geocoder;
                              openaddr    : OpenAddr_Geocoder | undef;
                              alternatives: Map[STRING → STRING];
                              cache       : Cache | undef ⟩⟩

    Init : Params → GeoCoderFreeState
    âˆ€ p : Params •
      let oa_path == p.openaddr ∨ env.OPENADDR_HOME •
      GeoCoderFreeState.openaddr = if oa_path ≠ ∅ then OpenAddresses(oa_path) else undef fi

=head2 geocode

    Geocode : Address × Region? → Point?
    âˆ€ addr : Address; r : Region? •
      let backends == (openaddr ≠ undef ⟹ [OpenAddresses, Local, MaxMind])
                    âˆ§ (openaddr = undef ⟹ [MaxMind]) •
      result = first { defined } map { b.geocode(addr, r) } backends

=head1 LICENSE AND COPYRIGHT

Copyright 2017-2026 Nigel Horne.  Licensed under GPL2 for personal use.

This product uses GeoLite2 data created by MaxMind,
available from L<https://www.maxmind.com/>.

=cut
871
8721;
873
874# Common mappings for looser lookups.  A future version should load these from
875# an external, user-editable config file.  See also Local.pm's %alternatives.