snailmailaddressparser
Version:
A Javascript library to parse snail mail addresses into components
194 lines (157 loc) • 6.17 kB
text/coffeescript
###
# This is the base class for all strategies
#
# TODO: get postal verification from the Universal Postal Union
#
# TODO: Address verification with eg Geocoder integration
#
# TODO: Make the Perl gods happy by outputting a big, evil regular expression
#
# TODO: Return an object that has multiple ambiguous addresses (i.e. matches
# multiple destinations)
###
###
# AddressStrategy
# ---------------
#
# This class is intended to be subclassed. The `do_parse_address` function
# should take a series of lines and return a simple JSONable object that
# adequately describes the components of the address.
#
# A given `subclass` should, after its definition, call subclass.register()
###
class AddressStrategy
@_registered_strategies = {}
###
* Register this subclass as a strategy
* Maps @name to the subclass calling register (@name is the canonical
* iso3166 name)
###
register: ->
name = @name.toLowerCase()
if not name
throw new Error("Strategies require a `name`")
if name in AddressStrategy._registered_strategies
throw new Error("Strategy #{name} registered twice")
AddressStrategy._registered_strategies[name] = @
@all_strategies: ->
return @_registered_strategies
#
# Given a set of regular expressions, see if each one applies to the given
# `lines`, in order, and return the results if it matches - otherwise
# throw an error. Fields is a list of named fields copied to the result.
#
# @param: regexes - a list of regular expressions, one corresponding to each
# line
# @ param: a list of lines of the address
#
run_rex_line_strategy: (matcher_array, addr_lines) ->
result = {}
if matcher_array.length != addr_lines.length
throw new Error("Matcher expects #{matcher_array.length} lines," +
" but the address has #{addr_lines.length} lines.")
_.each(matcher_array, (line_strategy, index) ->
matches = line_strategy.match(addr_lines[index])
if matches == null
throw new Error("Line #{index} is not a #{line_strategy.names()}")
# each line should assign to unique elements
# e.g.
# Suite 100 - 50 Wallaby Lane
# Unit #125
# is not a valid address because suite matches on both the first and
# second lines
overlapping = _.intersection(result, matches)
if not _.isEmpty(overlapping)
throw new Error("Overlapping elements: #{inspect overlapping}")
_.extend(result, matches)
# _.each matches, (key) -> result[key] = matches[key]
)
return result
# try every strategy on addr_lines
run_line_strategies: (strategies, addr_lines) ->
results = null
# early out. TODO: Return multiple results (if conflicting)
_.any(strategies, (strat, index) =>
try
results = @run_rex_line_strategy(strat, addr_lines)
catch err
# console.log "Strategy #{index} failed: #{err}"
return false
return results != null
)
return results
debug_line_strategies: (strategies, addr_lines) ->
addr_str = _.map(addr_lines, (line, idx) -> "#{idx}: #{line}").join("\n")
console.log("\n** Debugging:\n#{addr_str}")
_.each(strategies, (strat, index) =>
# console.log "Skipping strategy #{index}; mismatched line count"
# silently skip strategies that correspond to different line counts
if strat.length != addr_lines.length
return
try
results = @run_rex_line_strategy(strat, addr_lines)
catch err
console.log "Rex failed: #{err}"
#line = parseInt(err) # line no is the problem
#rex = strat[line] # the regular expression that failed
#console.log "Line #{line}: \"#{addr_lines[line]}\" " +
# "does not match /^#{rex}$/ (#{err})"
)
#
# parse_address
# ~~~~~~~~~~~~~
#
# Given lines/address, parse it components.
# Address_string is provided as a backup, in case a 'lines' strategy is not
# workable.
#
parse_address: (lines, address_string, debug=false) ->
if lines.length < 2
throw new Error("Addresses must be at least two lines.")
line_strats = @line_strategies() or []
results = @run_line_strategies(line_strats, lines) or {}
# TODO: check if we are in debug mode, and only dump this info when we are
if _.isEmpty(results)
if debug
@debug_line_strategies(line_strats, lines)
else
throw new Error("This address does not match a known format.")
return _.defaults(results, @expected_fields())
AddressStrategy.do_parse_address = (addr_string, options) ->
options = _.defaults(options,
defaultCountry: ''
debug: false
)
strategies = []
matches = []
# split the address into usable lines
# - skip any empty space
# - trim whitespace from lines
lines = _.filter(_.map(
addr_string.split('\n'), (aline) -> aline.trim()), _.identity
)
if lines.length < 2
# this may be a Western convention.
throw new Error("Addresses must be at least two lines long")
# First, we need to get the country. It should be the last line of the
# address, but if the last line is not a recognized country then we use
# the defaultCountry passed as an argument or alternatively the
# defaultCountry passed as an argument to the constructor of this class.
last_line = lines[lines.length - 1]
if last_line.toLowerCase() in _.keys(COUNTRY_NAMES_MAP)
# we've been given a country.
country = lines.pop().toLowerCase()
else if options.defaultCountry
# use the default argument to this function
country = options.defaultCountry
if country
# convert from eg 'Canada' to 'canada' or "CA" to 'ca'
country_key = COUNTRY_NAMES_MAP[country.toLowerCase()]
if country_key in _.keys(AddressStrategy._registered_strategies)
strategies = [AddressStrategy._registered_strategies[country_key]]
if _.isEmpty(strategies)
# O_RLY? Try every country.
strategies = _.values(AddressStrategy._registered_strategies)
for strat in strategies
matches.push(strat.parse_address(lines, addr_string, options.debug))
return { matches: _.flatten(matches) }