UNPKG

@sroussey/parse-address

Version:

International street address parser for 220 jurisdictions (US, CA + 218 countries and territories), with SEC EDGAR country-code resolution

361 lines 16.3 kB
import XRegExp from "xregexp"; import { buildEuRuleset } from "./ruleset.js"; const NUMERIC = /^\d+$/; /** * Generic, configuration-driven parser for European addresses. One instance per * country, built from an `EuCountryConfig`. Implements the same surface as the * US/CA parsers so it drops straight into the `AddressParser` facade. */ export class AddressParserEU { constructor(config) { var _a, _b; Object.defineProperty(this, "config", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "ruleset", { enumerable: true, configurable: true, writable: true, value: void 0 }); // Precomputed once per country (these are invariant across parse calls). Object.defineProperty(this, "poAnywhereRe", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "fusedSuffixesSorted", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "unsplittablePrefixRe", { enumerable: true, configurable: true, writable: true, value: void 0 }); // Locational tokens the most recent parse deliberately dropped (e.g. a ZA // suburb superseded by the routing city), so the token-preservation guard // does not score them as lost. postNormalize records them via `parsed.__dropped`. Object.defineProperty(this, "lastDropped", { enumerable: true, configurable: true, writable: true, value: [] }); this.config = config; this.ruleset = buildEuRuleset(config); // Detect the PO-box word anywhere: it may lead the line, or sit after a // leading building ("Sea Meadow House, PO Box 116, ..."). The anchored // po_address grammar simply returns null when it does not actually fit. this.poAnywhereRe = XRegExp(`(?:${this.ruleset.po_box})(?![A-Za-z])`, "xi"); // Longest suffix first so "strasse" wins over "str" regardless of config order. this.fusedSuffixesSorted = [...((_a = config.fusedTypeSuffixes) !== null && _a !== void 0 ? _a : [])].sort((a, b) => b[0].length - a[0].length); const prefixes = (_b = config.unsplittablePrefixes) !== null && _b !== void 0 ? _b : []; this.unsplittablePrefixRe = prefixes.length ? new RegExp(`^(?:${prefixes.map((p) => XRegExp.escape(p)).join("|")})\\s`, "i") : null; } // --- public surface ------------------------------------------------------- parseAddress(address) { return this.normalizeAddress(XRegExp.exec(address, this.ruleset.address)); } parseStreet(streetaddress) { return this.normalizeAddress(XRegExp.exec(streetaddress, this.ruleset.street_address)); } parseInformalAddress(address) { return this.normalizeAddress(XRegExp.exec(address, this.ruleset.informal_address)); } parsePoAddress(address) { return this.normalizeAddress(XRegExp.exec(address, this.ruleset.po_address)); } parseLocation(address) { if (this.poAnywhereRe.test(address)) { const po = this.parsePoAddress(address); if (po) return po; } // A pure location line (postcode + city, no street) is anchored end-to-end, // so a real street line can never match it -- try it before the street // grammar, which otherwise swallows a bare postcode as a numeric street. // Only for postcode-first (continental) layouts: with the postcode last, a // number-less street line ("Bruntsfield Place, Edinburgh, GIR 0AA") is // indistinguishable from "city, county, postcode", so we must parse it as a // street instead. if (this.config.postalPlacement === "before-city") { const placeOnly = this.normalizeAddress(XRegExp.exec(address, this.ruleset.place_only)); if (placeOnly) return placeOnly; } // A settlement-only line ("Bodden Town, Grand Cayman, KY1-1601") has no // street; its required island suffix keeps it from claiming a real street, // so try it before parseAddress (which would take the settlement as street). if (this.ruleset.settlement_only) { const s = this.normalizeAddress(XRegExp.exec(address, this.ruleset.settlement_only)); if (s) return s; } // A building-only line ("OMC Chambers, Wickhams Cay 1, Road Town, ...") has // no street of its own; its mandatory area marker keeps it from competing // with an ordinary building-then-street line, so try it before parseAddress. if (this.ruleset.building_address) { const b = this.normalizeAddress(XRegExp.exec(address, this.ruleset.building_address)); if (b) return b; } return this.parseAddress(address) || this.parseInformalAddress(address); } // European addresses do not use US-style street intersections; expose the // method for interface parity but return null. parseIntersection(_address) { return null; } droppableTokens() { var _a; return [...((_a = this.config.areaNames) !== null && _a !== void 0 ? _a : []), ...this.lastDropped]; } findStreetTypeShortCode(streetType) { const blank = "BL"; if (!streetType) return blank; const map = this.config.typeShortCodeMap; if (!map) return blank; const key = streetType.toLowerCase(); const direct = map[key]; if (direct) return direct; // The caller may pass a raw spelling/abbreviation; canonicalise first. const display = this.config.typeDisplayMap[key]; const viaDisplay = display ? map[display.toLowerCase()] : undefined; if (viaDisplay) return viaDisplay; return blank; } // --- normalization -------------------------------------------------------- normalizeAddress(parts) { // Reset per call so a failed match (or an empty parse) never leaves the // previous address's dropped tokens visible through droppableTokens(). this.lastDropped = []; if (!parts) return null; const parsed = {}; // Values captured by a `drop` group (a neighbourhood/bairro/colonia the // grammar consumes but does not emit) are recorded as dropped so the // token-preservation guard does not score them as lost. const dropped = []; Object.keys(parts).forEach((part) => { if (["input", "index"].includes(part) || NUMERIC.test(part)) return; // Group names may carry a numeric disambiguation suffix (street_1); the // logical field is the name with that suffix removed. const tail = part.split("_").pop(); const key = tail && NUMERIC.test(tail) ? part.split("_").slice(0, -1).join("_") : part; const value = parts[part]; if (value == null) return; const cleaned = this.cleanValue(String(value)); if (!cleaned) return; if (key === "drop") dropped.push(cleaned); else parsed[key] = cleaned; }); if (Object.keys(parsed).length === 0) return null; this.normalizeNumberSuffix(parsed); this.applyFusedType(parsed); this.foldArticleType(parsed); this.normalizeType(parsed); this.normalizeSecUnit(parsed); this.normalizePostal(parsed); this.normalizeRegion(parsed); this.tidyCity(parsed); this.lastDropped = dropped; if (this.config.postNormalize) this.config.postNormalize(parsed); // postNormalize may report further dropped locational tokens (open-ended // values that cannot be listed in `areaNames`), exempting them too. if (Array.isArray(parsed.__dropped)) { this.lastDropped = [ ...dropped, ...parsed.__dropped.filter((t) => typeof t === "string"), ]; delete parsed.__dropped; } parsed.country = this.config.country; return parsed; } /** * Tidy a civic-number suffix: collapse whitespace around a range dash * ("24 - 26" -> "24-26") and trim the ends, but keep an internal space in * markers like the Spanish "km 176". */ normalizeNumberSuffix(parsed) { if (parsed.civic_number_suffix) { parsed.civic_number_suffix = String(parsed.civic_number_suffix) .replace(/\s*-\s*/g, "-") .replace(/^[\s]+|[\s]+$/g, ""); } } /** Trim surrounding whitespace/commas while preserving accents and casing. */ cleanValue(value) { return value .replace(/\s+/g, " ") .replace(/^[\s,]+/, "") .replace(/[\s,]+$/, "") .trim(); } /** * Peel a glued street type off the end of the name (DE/NL "Bäckerstraße"). * Runs for any country that supplies `fusedTypeSuffixes` -- including * mixed-grammar countries (BE/CH) whose `prefix` French/Italian streets are * already typed (so this is a no-op there) but whose Dutch/German streets * arrive here untyped. */ applyFusedType(parsed) { var _a, _b, _c; if (!((_a = this.config.fusedTypeSuffixes) === null || _a === void 0 ? void 0 : _a.length)) return; if (!parsed.street || parsed.type) return; // Prepositional/article-led names ("Am Weidendamm", "Unter den Linden") are // unsplittable: keep the whole name and leave the type empty. if (this.unsplittablePrefixRe && this.unsplittablePrefixRe.test(parsed.street)) { return; } const exact = (_b = this.config.unsplittableExact) !== null && _b !== void 0 ? _b : []; if (exact.some((n) => n.toLowerCase() === parsed.street.toLowerCase())) return; // A trailing abbreviation dot ("Berliner Str.") should not defeat the match. const base = String(parsed.street).replace(/\.\s*$/, ""); const lower = base.toLowerCase(); const splitSpaced = this.config.splitSpacedType !== false; for (const [spelling, display] of this.fusedSuffixesSorted) { const s = spelling.toLowerCase(); if (!lower.endsWith(s)) continue; // When the type is a *separate word* ("Kärntner Straße", "Landstraßer // Hauptstraße"), the type is that whole last word -- not a mid-word split // ("...Haupt" + "straße"). This applies only where spaced types split. const lastSpace = base.lastIndexOf(" "); if (splitSpaced && lastSpace >= 1) { const lastWord = base.slice(lastSpace + 1); const lw = lastWord.toLowerCase(); const isWholeType = this.config.spacedTypeExact ? lw === s : lw.endsWith(s); if (isWholeType) { parsed.street = base.slice(0, lastSpace).replace(/[\s-]+$/, "").trim(); parsed.type = lastWord; // normalizeType canonicalises it afterwards return; } } // Otherwise peel the glued suffix from within the single word. const remainder = base.slice(0, base.length - s.length); if (remainder.trim().length < ((_c = this.config.minFusedStem) !== null && _c !== void 0 ? _c : 3)) continue; // When spaced types are not split (Dutch/Belgian), a preceding *space* // means the suffix is a separate word ("Grote Markt") that stays whole; a // hyphen still counts as joined ("Albert I-laan" -> "Albert I" + "laan"). if (!splitSpaced && /\s$/.test(remainder)) continue; parsed.street = remainder.replace(/[\s-]+$/, "").trim(); parsed.type = display; return; } } /** "The" + suffix type ("The Broadway") is one whole name, not a split. */ foldArticleType(parsed) { var _a; const articles = (_a = this.config.articleNames) !== null && _a !== void 0 ? _a : []; if (!parsed.type || !parsed.street || !articles.length) return; if (articles.some((a) => a.toLowerCase() === parsed.street.toLowerCase())) { parsed.street = `${parsed.street} ${parsed.type}`.trim(); delete parsed.type; } } /** Canonicalise the street type display form and compute its short code. */ normalizeType(parsed) { var _a; if (!parsed.type) return; const raw = String(parsed.type).replace(/\.$/, "").toLowerCase(); if (this.config.normalizeTypeCase === false) { // Keep the type exactly as written; only derive the short code. const short = this.findStreetTypeShortCode(raw); if (short !== "BL") parsed.short_street_type = short; return; } const display = (_a = this.config.typeDisplayMap[raw]) !== null && _a !== void 0 ? _a : this.titleCase(parsed.type); parsed.type = display; const short = this.findStreetTypeShortCode(display); if (short !== "BL") parsed.short_street_type = short; } normalizeSecUnit(parsed) { var _a, _b; if (parsed.sec_unit_type) { const rawLower = String(parsed.sec_unit_type).toLowerCase(); const key = rawLower.replace(/\.$/, ""); const sm = (_a = this.config.secUnitDisplayMap) !== null && _a !== void 0 ? _a : {}; const pm = (_b = this.config.poBoxDisplayMap) !== null && _b !== void 0 ? _b : {}; const display = sm[key] || sm[rawLower] || pm[key] || pm[rawLower]; if (display) parsed.sec_unit_type = display; } // A secondary unit captured without its own type word (e.g. a Spanish floor // "3.º B") gets the country's default type ("Piso"). if (!parsed.sec_unit_type && parsed.sec_unit_num && this.config.defaultSecUnitType) { parsed.sec_unit_type = this.config.defaultSecUnitType; } // Grouped PO-box numbers ("10 01 20") join into one token ("100120"). Only // all-digit grouped runs are collapsed; a floor like "3.º B" keeps its space. if (parsed.sec_unit_num && /^\d[\d\s-]*\d$/.test(parsed.sec_unit_num)) { parsed.sec_unit_num = String(parsed.sec_unit_num).replace(/\s+/g, ""); } } normalizePostal(parsed) { if (!parsed.postal_code) return; const fmt = this.config.postalFormat; parsed.postal_code = fmt ? fmt(parsed.postal_code) : String(parsed.postal_code).replace(/\s+/g, " ").trim(); } normalizeRegion(parsed) { if (!parsed.state) return; const map = this.config.regionMap; const key = String(parsed.state).toLowerCase().replace(/[()]/g, "").trim(); const mapped = map && map[key]; if (mapped) { parsed.state = mapped; } else if (/^[a-z]{2}$/i.test(key)) { parsed.state = key.toUpperCase(); } else { // Free-form region/county (e.g. UK "Surrey"): keep as written, trimmed. parsed.state = String(parsed.state).replace(/^[\s,]+|[\s,]+$/g, ""); } } tidyCity(parsed) { if (!parsed.city) return; parsed.city = String(parsed.city).replace(/\s+/g, " ").trim(); } titleCase(s) { return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); } } //# sourceMappingURL=parser.js.map