| File: | blib/lib/Geo/Coder/Free.pm |
| Coverage: | 79.2% |
| line | stmt | bran | cond | sub | pod | time | code |
|---|---|---|---|---|---|---|---|
| 1 | package 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 | |||||||
| 28 | our $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 | # ----------------------------------------------------------------------- | ||||||
| 120 | our $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 | # ----------------------------------------------------------------------- | ||||||
| 126 | my %_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. | ||||||
| 139 | Readonly::Scalar my $CONF_TRIPLET => 0.8; | ||||||
| 140 | Readonly::Scalar my $CONF_DUPLET => 0.7; | ||||||
| 141 | Readonly::Scalar my $CONF_REGEX => 0.7; | ||||||
| 142 | |||||||
| 143 | # Stopwords excluded from word-window scantext searches. | ||||||
| 144 | my %_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 | |||||||
| 200 | sub 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 | |||||||
| 319 | sub 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 | |||||||
| 518 | sub 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 | |||||||
| 566 | sub 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. | ||||||
| 589 | sub _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. | ||||||
| 603 | sub _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. | ||||||
| 626 | sub _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. | ||||||
| 654 | sub _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. | ||||||
| 670 | sub _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. | ||||||
| 690 | sub _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. | ||||||
| 722 | sub _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 | |||||||
| 872 | 1; | ||||||
| 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. | ||||||