@mailwoman/record 7.1.0 → 7.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/address.ts +116 -0
- package/index.ts +16 -0
- package/name.ts +303 -0
- package/organization.ts +267 -0
- package/package.json +13 -5
package/address.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The canonical address record — the matcher's unit of address identity, and the canonical record
|
|
7
|
+
* the organization and contact records build on.
|
|
8
|
+
*
|
|
9
|
+
* It is plain data: parser components + the formatter's match key + an optional resolved geocode,
|
|
10
|
+
* composed into one object. No ORM, no decorators, no schema-generation machinery — if we need a
|
|
11
|
+
* database we reach for Kysely at the call site, not a model layer here.
|
|
12
|
+
*
|
|
13
|
+
* The geocode fields mirror mailwoman's `GeocodeResult` (tier + calibrated uncertainty + hierarchy)
|
|
14
|
+
* on purpose: that is the location signal the Fellegi-Sunter scorer weights its distance evidence
|
|
15
|
+
* by — two records sharing a `address_point` coordinate is strong agreement; sharing an
|
|
16
|
+
* `interpolated` centroid is weak; a PO-box / multi-unit coordinate is barely location agreement
|
|
17
|
+
* at all (the NAACCR precedent, see the geocode-first record-matching concept doc).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type ComponentDict, type FormatAddressOptions, canonicalKey, formatAddress } from "@mailwoman/formatter"
|
|
21
|
+
|
|
22
|
+
/** A geographic coordinate (WGS84 decimal degrees). */
|
|
23
|
+
export interface GeoCoordinate {
|
|
24
|
+
latitude: number
|
|
25
|
+
longitude: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The resolution tier that produced a coordinate, mirroring mailwoman's geocoder (`address_point` > `interpolated` >
|
|
30
|
+
* `street` > `admin`). Kept as a local plain union so this package stays decoupled from the heavy geocoder runtime; a
|
|
31
|
+
* `GeocodeResult.resolution_tier` maps in directly. (`street` = a street centroid for a street-only query, #1042 —
|
|
32
|
+
* coarser than a house-number estimate, finer than an admin centroid.)
|
|
33
|
+
*/
|
|
34
|
+
export type ResolutionTier = "address_point" | "interpolated" | "street" | "admin"
|
|
35
|
+
|
|
36
|
+
/** One resolved admin-hierarchy ancestor (most specific first), for spelling-invariant blocking. */
|
|
37
|
+
export interface HierarchyNode {
|
|
38
|
+
tag: string
|
|
39
|
+
value: string
|
|
40
|
+
placeID?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A resolved geocode attached to an address record — the location signal the matcher scores on. */
|
|
44
|
+
export interface AddressGeocode {
|
|
45
|
+
coordinate: GeoCoordinate
|
|
46
|
+
tier: ResolutionTier
|
|
47
|
+
/** Calibrated uncertainty radius in meters; `null` for the admin tier (no sub-locality estimate). */
|
|
48
|
+
uncertaintyMeters: number | null
|
|
49
|
+
/** Resolved admin hierarchy, locality → country (most specific first). */
|
|
50
|
+
hierarchy?: HierarchyNode[]
|
|
51
|
+
/** A delivery point, not a building — weakens location agreement even at a precise coordinate. */
|
|
52
|
+
poBox?: boolean
|
|
53
|
+
/** A multi-unit building where many records share one coordinate — weakens unit-level agreement. */
|
|
54
|
+
multiUnit?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The canonical address record. Composes the parser's components, the formatter's match key, an optional human-readable
|
|
59
|
+
* form, and an optional resolved geocode. Plain data — no behavior.
|
|
60
|
+
*/
|
|
61
|
+
export interface PostalAddress {
|
|
62
|
+
/** Parsed address components (`ComponentTag`-keyed). */
|
|
63
|
+
components: ComponentDict
|
|
64
|
+
/** Normalized, deterministic match key for blocking (from `@mailwoman/formatter`). */
|
|
65
|
+
canonicalKey: string
|
|
66
|
+
/** Optional human-readable single-line form, for display. */
|
|
67
|
+
formatted?: string
|
|
68
|
+
/** Resolved location, when geocoded. */
|
|
69
|
+
geocode?: AddressGeocode
|
|
70
|
+
/** The original free-text input, when known (provenance). */
|
|
71
|
+
raw?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Options for {@linkcode toPostalAddress}. */
|
|
75
|
+
export interface ToPostalAddressOptions {
|
|
76
|
+
/** Country (ISO-2 or name) for formatting. Defaults to the `country` component, else unset. */
|
|
77
|
+
country?: string
|
|
78
|
+
/** The original free-text input to retain as provenance. */
|
|
79
|
+
raw?: string
|
|
80
|
+
/** Also compute a human-readable `formatted` string. Default `true`. */
|
|
81
|
+
format?: boolean
|
|
82
|
+
/** Formatting options forwarded to the formatter. Defaults to single-line (`", "`). */
|
|
83
|
+
formatOptions?: FormatAddressOptions
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a canonical {@linkcode PostalAddress} from parsed components: fills the match key (always) and a human-readable
|
|
88
|
+
* form (unless disabled). Attach a geocode separately with {@linkcode withGeocode} once the address is resolved.
|
|
89
|
+
*/
|
|
90
|
+
export function toPostalAddress(components: ComponentDict, opts: ToPostalAddressOptions = {}): PostalAddress {
|
|
91
|
+
const country = opts.country ?? components.country ?? ""
|
|
92
|
+
|
|
93
|
+
const record: PostalAddress = {
|
|
94
|
+
components,
|
|
95
|
+
canonicalKey: canonicalKey(components),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (opts.raw !== undefined) {
|
|
99
|
+
record.raw = opts.raw
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (opts.format !== false) {
|
|
103
|
+
const formatted = formatAddress(components, country, opts.formatOptions ?? { separator: ", " })
|
|
104
|
+
|
|
105
|
+
if (formatted) {
|
|
106
|
+
record.formatted = formatted
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return record
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Attach (or replace) a resolved geocode on an address record, returning a new record. */
|
|
114
|
+
export function withGeocode(record: PostalAddress, geocode: AddressGeocode): PostalAddress {
|
|
115
|
+
return { ...record, geocode }
|
|
116
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `@mailwoman/record` — the canonicalize layer for the geocode-first matcher.
|
|
7
|
+
*
|
|
8
|
+
* Address-first: {@linkcode PostalAddress} is the canonical record. The per-field normalizers
|
|
9
|
+
* ({@linkcode parsePersonName}, {@linkcode canonicalizeOrganizationName}) build on the same
|
|
10
|
+
* plain-data pattern. Contact records and the comparator/Fellegi-Sunter layer land in the
|
|
11
|
+
* matcher.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export * from "./address.ts"
|
|
15
|
+
export * from "./name.ts"
|
|
16
|
+
export * from "./organization.ts"
|
package/name.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Person-name parsing — split a full name into components for the matcher to canonicalize and
|
|
7
|
+
* compare.
|
|
8
|
+
*
|
|
9
|
+
* A rule-based positional parser, the portable recipe from `python-nameparser`: split on a comma to
|
|
10
|
+
* detect `Last, First` inversion, then classify tokens by position against configurable title /
|
|
11
|
+
* suffix / particle lists. We deliberately store the surname **particle** (`van`, `de la`, `von`)
|
|
12
|
+
* separately from the bare surname (the `theiconic/name-parser` pattern) so the matcher can
|
|
13
|
+
* compare `Vega` independent of `de la` — sources that drop or vary the particle still match.
|
|
14
|
+
*
|
|
15
|
+
* Scope + honesty (per the name-canonicalization research pass):
|
|
16
|
+
*
|
|
17
|
+
* - Western / romanized names only. Cultural given-family ORDER variation (East-Asian family-first)
|
|
18
|
+
* and transliteration are not handled here — a documented follow-up.
|
|
19
|
+
* - Nickname → canonical-root mapping is intentionally NOT done at parse time: it is lossy and
|
|
20
|
+
* gendered (Bobbie → Robert _or_ Roberta), so equivalence belongs in the matcher as a fuzzy
|
|
21
|
+
* agreement level, not a destructive rewrite. We only _extract_ a parenthetical/quoted
|
|
22
|
+
* nickname.
|
|
23
|
+
* - A CRF parser (probablepeople) is the gold-standard reference but too heavy to port; this
|
|
24
|
+
* positional parser covers the documented hard cases (inversion, particles, generational +
|
|
25
|
+
* professional suffixes) without a model.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** A parsed person name. All fields optional — the parser fills what it can identify. */
|
|
29
|
+
export interface PersonName {
|
|
30
|
+
/** Title / salutation that preceded the name (`Dr`, `Mr`, `Capt`). */
|
|
31
|
+
prefix?: string
|
|
32
|
+
/** First / given name. */
|
|
33
|
+
given?: string
|
|
34
|
+
/** Middle name(s) or initial. */
|
|
35
|
+
middle?: string
|
|
36
|
+
/** Surname, _without_ any particle (`Vega`, not `de la Vega`). */
|
|
37
|
+
family?: string
|
|
38
|
+
/** Surname particle, stored separately (`de la`, `van der`, `von`). */
|
|
39
|
+
familyParticle?: string
|
|
40
|
+
/** Generational or professional suffix (`Jr`, `III`, `PhD`, `MD`). */
|
|
41
|
+
suffix?: string
|
|
42
|
+
/** A parenthetical or quoted nickname (`"Gob"` in `George "Gob" Bluth`). */
|
|
43
|
+
nickname?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Titles / salutations that lead a name. Matched case-insensitively, trailing `.` ignored. */
|
|
47
|
+
const TITLES = new Set([
|
|
48
|
+
"airman",
|
|
49
|
+
"br",
|
|
50
|
+
"brig",
|
|
51
|
+
"brigadier",
|
|
52
|
+
"capt",
|
|
53
|
+
"captain",
|
|
54
|
+
"cmdr",
|
|
55
|
+
"col",
|
|
56
|
+
"colonel",
|
|
57
|
+
"commander",
|
|
58
|
+
"commissioner",
|
|
59
|
+
"cpl",
|
|
60
|
+
"cpt",
|
|
61
|
+
"dep",
|
|
62
|
+
"deputy",
|
|
63
|
+
"doctor",
|
|
64
|
+
"dr",
|
|
65
|
+
"father",
|
|
66
|
+
"fr",
|
|
67
|
+
"gen",
|
|
68
|
+
"general",
|
|
69
|
+
"hon",
|
|
70
|
+
"honorable",
|
|
71
|
+
"judge",
|
|
72
|
+
"lt",
|
|
73
|
+
"ltcol",
|
|
74
|
+
"ltgen",
|
|
75
|
+
"maj",
|
|
76
|
+
"major",
|
|
77
|
+
"master",
|
|
78
|
+
"miss",
|
|
79
|
+
"mr",
|
|
80
|
+
"mrs",
|
|
81
|
+
"ms",
|
|
82
|
+
"mx",
|
|
83
|
+
"pastor",
|
|
84
|
+
"pfc",
|
|
85
|
+
"pres",
|
|
86
|
+
"president",
|
|
87
|
+
"private",
|
|
88
|
+
"prof",
|
|
89
|
+
"professor",
|
|
90
|
+
"pvt",
|
|
91
|
+
"rabbi",
|
|
92
|
+
"rep",
|
|
93
|
+
"representative",
|
|
94
|
+
"rev",
|
|
95
|
+
"reverend",
|
|
96
|
+
"sen",
|
|
97
|
+
"senator",
|
|
98
|
+
"sgt",
|
|
99
|
+
"sir",
|
|
100
|
+
"sister",
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
/** Generational + professional suffixes that trail a name. */
|
|
104
|
+
const SUFFIXES = new Set([
|
|
105
|
+
// generational
|
|
106
|
+
"jr",
|
|
107
|
+
"sr",
|
|
108
|
+
"i",
|
|
109
|
+
"ii",
|
|
110
|
+
"iii",
|
|
111
|
+
"iv",
|
|
112
|
+
"v",
|
|
113
|
+
"vi",
|
|
114
|
+
"vii",
|
|
115
|
+
"viii",
|
|
116
|
+
// professional / honorific
|
|
117
|
+
"phd",
|
|
118
|
+
"md",
|
|
119
|
+
"do",
|
|
120
|
+
"dds",
|
|
121
|
+
"dmd",
|
|
122
|
+
"dvm",
|
|
123
|
+
"esq",
|
|
124
|
+
"esquire",
|
|
125
|
+
"jd",
|
|
126
|
+
"llm",
|
|
127
|
+
"cpa",
|
|
128
|
+
"rn",
|
|
129
|
+
"lpn",
|
|
130
|
+
"pa",
|
|
131
|
+
"pe",
|
|
132
|
+
"od",
|
|
133
|
+
"dc",
|
|
134
|
+
"dpm",
|
|
135
|
+
"psyd",
|
|
136
|
+
"edd",
|
|
137
|
+
"mba",
|
|
138
|
+
"mfa",
|
|
139
|
+
"msw",
|
|
140
|
+
"pharmd",
|
|
141
|
+
])
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Surname particles. Consecutive particles fold together (`de` + `la` → `de la`), and the next non-particle token
|
|
145
|
+
* begins the bare surname.
|
|
146
|
+
*/
|
|
147
|
+
const PARTICLES = new Set([
|
|
148
|
+
"al",
|
|
149
|
+
"bin",
|
|
150
|
+
"da",
|
|
151
|
+
"das",
|
|
152
|
+
"de",
|
|
153
|
+
"del",
|
|
154
|
+
"della",
|
|
155
|
+
"den",
|
|
156
|
+
"der",
|
|
157
|
+
"di",
|
|
158
|
+
"do",
|
|
159
|
+
"dos",
|
|
160
|
+
"du",
|
|
161
|
+
"el",
|
|
162
|
+
"ibn",
|
|
163
|
+
"la",
|
|
164
|
+
"le",
|
|
165
|
+
"lo",
|
|
166
|
+
"mac",
|
|
167
|
+
"mc",
|
|
168
|
+
"san",
|
|
169
|
+
"santa",
|
|
170
|
+
"st",
|
|
171
|
+
"ter",
|
|
172
|
+
"van",
|
|
173
|
+
"vande",
|
|
174
|
+
"vanden",
|
|
175
|
+
"vander",
|
|
176
|
+
"vere",
|
|
177
|
+
"von",
|
|
178
|
+
"zu",
|
|
179
|
+
"zur",
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
const isPresent = (s: string | undefined | null): s is string => typeof s === "string" && s.trim().length > 0
|
|
183
|
+
const norm = (token: string): string => token.replace(/\.$/, "").toLowerCase()
|
|
184
|
+
const countChar = (s: string, c: string): number => s.split(c).length - 1
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Parse a full name into components. Returns `null` for empty input. Best-effort and non-throwing — ambiguous input
|
|
188
|
+
* degrades gracefully rather than erroring.
|
|
189
|
+
*/
|
|
190
|
+
export function parsePersonName(input: string | null | undefined): PersonName | null {
|
|
191
|
+
if (!isPresent(input)) return null
|
|
192
|
+
|
|
193
|
+
const result: PersonName = {}
|
|
194
|
+
|
|
195
|
+
// 1. Extract a parenthetical "(Jim)" or quoted "Jim" nickname, then strip it out.
|
|
196
|
+
let working = input
|
|
197
|
+
.replace(/\s*\(([^)]+)\)\s*/g, (_m, n: string) => {
|
|
198
|
+
if (!result.nickname) {
|
|
199
|
+
result.nickname = n.trim()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return " "
|
|
203
|
+
})
|
|
204
|
+
.replace(/\s*"([^"]+)"\s*/g, (_m, n: string) => {
|
|
205
|
+
if (!result.nickname) {
|
|
206
|
+
result.nickname = n.trim()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return " "
|
|
210
|
+
})
|
|
211
|
+
.replace(/\s+/g, " ")
|
|
212
|
+
.trim()
|
|
213
|
+
|
|
214
|
+
// 2. Resolve a single comma: "Last, First" inversion, unless the tail is a known suffix
|
|
215
|
+
// ("John Smith, Jr."), in which case keep order and treat the tail as a suffix.
|
|
216
|
+
if (countChar(working, ",") === 1) {
|
|
217
|
+
const [head, tail] = working.split(",").map((p) => p.trim())
|
|
218
|
+
|
|
219
|
+
if (tail && tail.split(/\s+/).every((t) => SUFFIXES.has(norm(t)))) {
|
|
220
|
+
result.suffix = tail
|
|
221
|
+
working = head!
|
|
222
|
+
} else if (head && tail) {
|
|
223
|
+
working = `${tail} ${head}`
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const tokens = working.split(/\s+/).filter(Boolean)
|
|
228
|
+
|
|
229
|
+
if (tokens.length === 0) return Object.keys(result).length ? result : null
|
|
230
|
+
|
|
231
|
+
// 3. Leading titles → prefix.
|
|
232
|
+
const prefixParts: string[] = []
|
|
233
|
+
|
|
234
|
+
while (tokens.length > 1 && TITLES.has(norm(tokens[0]!))) {
|
|
235
|
+
prefixParts.push(tokens.shift()!)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (prefixParts.length) {
|
|
239
|
+
result.prefix = prefixParts.join(" ")
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 4. Trailing suffixes → suffix (a single name token must remain).
|
|
243
|
+
const suffixParts: string[] = []
|
|
244
|
+
|
|
245
|
+
while (tokens.length > 1 && SUFFIXES.has(norm(tokens[tokens.length - 1]!))) {
|
|
246
|
+
suffixParts.unshift(tokens.pop()!)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (suffixParts.length) {
|
|
250
|
+
result.suffix = isPresent(result.suffix) ? `${suffixParts.join(" ")} ${result.suffix}` : suffixParts.join(" ")
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (tokens.length === 0) return result
|
|
254
|
+
|
|
255
|
+
// 5. Locate the surname particle run; everything from it onward is the (particled) surname.
|
|
256
|
+
let particleStart = -1
|
|
257
|
+
|
|
258
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
259
|
+
// A particle only starts a surname if a bare-surname token follows it.
|
|
260
|
+
if (PARTICLES.has(norm(tokens[i]!)) && i < tokens.length - 1) {
|
|
261
|
+
particleStart = i
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (particleStart >= 0) {
|
|
267
|
+
let i = particleStart
|
|
268
|
+
const particleParts: string[] = []
|
|
269
|
+
|
|
270
|
+
while (i < tokens.length - 1 && PARTICLES.has(norm(tokens[i]!))) {
|
|
271
|
+
particleParts.push(tokens[i]!)
|
|
272
|
+
i++
|
|
273
|
+
}
|
|
274
|
+
result.familyParticle = particleParts.join(" ")
|
|
275
|
+
result.family = tokens.slice(i).join(" ")
|
|
276
|
+
const before = tokens.slice(0, particleStart)
|
|
277
|
+
|
|
278
|
+
if (before.length) {
|
|
279
|
+
result.given = before[0]
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (before.length > 1) {
|
|
283
|
+
result.middle = before.slice(1).join(" ")
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return result
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 6. No particle: last token is the surname, first is given, the rest is middle.
|
|
290
|
+
if (tokens.length === 1) {
|
|
291
|
+
result.given = tokens[0]
|
|
292
|
+
|
|
293
|
+
return result
|
|
294
|
+
}
|
|
295
|
+
result.given = tokens[0]
|
|
296
|
+
result.family = tokens[tokens.length - 1]
|
|
297
|
+
|
|
298
|
+
if (tokens.length > 2) {
|
|
299
|
+
result.middle = tokens.slice(1, -1).join(" ")
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return result
|
|
303
|
+
}
|
package/organization.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Organization-name canonicalization — reduce a company name to a stable, comparable key.
|
|
7
|
+
*
|
|
8
|
+
* Winkler's record-linkage recipe: words of little distinguishing power (the legal designation —
|
|
9
|
+
* `Corporation`, `Limited`, `LLC`) are normalized away before matching, so `Acme Corp` and `Acme
|
|
10
|
+
* Corporation, LLC` collapse to the same key. We also split off a `doing business as` clause and
|
|
11
|
+
* normalize connectives (`&` → `and`), punctuation, accents, and a leading `The`.
|
|
12
|
+
*
|
|
13
|
+
* **The collision problem (#668).** A legal-form token in one jurisdiction is a meaningful word in
|
|
14
|
+
* another domain. `PT` is Indonesia's `Perseroan Terbatas` (its LLC) — and US-healthcare
|
|
15
|
+
* shorthand for _Physical Therapy_. `SCA` / `SCS` are French/Belgian/Luxembourg commandite forms
|
|
16
|
+
* — and, in a clinic's name, _Sudden Cardiac Arrest_ / _Spinal Cord Stimulator_. A single
|
|
17
|
+
* universal strip-list can't be right for both: strip `PT` and you corrupt `Lakeside PT`; keep it
|
|
18
|
+
* and you leave the legal form on an Indonesian company. So the strip-set is computed on **two
|
|
19
|
+
* axes**:
|
|
20
|
+
*
|
|
21
|
+
* - **jurisdiction** (ISO 3166-1 alpha-2, e.g. from the resolved address country) — _adds_ the legal
|
|
22
|
+
* forms valid in that country. Collision-prone forms (`pt`, `sca`, `scs`) live here, gated
|
|
23
|
+
* behind a known jurisdiction, NOT in the universal base.
|
|
24
|
+
* - **domain** (an ingest-config tag, e.g. `healthcare`) — _protects_ domain-meaningful tokens from
|
|
25
|
+
* ever being stripped, even when a jurisdiction pack would add them. Domain protection wins.
|
|
26
|
+
*
|
|
27
|
+
* `effective = (base ∪ jurisdiction-pack) − domain-protect-pack`. With no options the set is the
|
|
28
|
+
* universal base and behavior is byte-for-byte unchanged — the new axes are strictly opt-in.
|
|
29
|
+
*
|
|
30
|
+
* Evidence honesty (per the name-canonicalization research pass): the PERSON-name side is well
|
|
31
|
+
* sourced; the ORGANIZATION side is a known evidence gap. This is a solid _canonicalization_
|
|
32
|
+
* baseline (the strip-designations principle is Winkler-grounded; the designation list draws on
|
|
33
|
+
* the ISO 20275 Entity Legal Forms register and `cleanco`). The jurisdiction/domain packs below
|
|
34
|
+
* are grounded seeds, not exhaustive — extend them per ISO 20275 as locales are added. The harder
|
|
35
|
+
* org-_matching_ problems — acronym ↔ expansion (`IBM` ↔ `International Business Machines`),
|
|
36
|
+
* DBA/alias resolution beyond the simple clause, subsidiary/parent, and TF-IDF n-gram token
|
|
37
|
+
* matching — are deferred to a follow-up (a dedicated org-matching research pass + the matcher
|
|
38
|
+
* epic).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** A canonicalized organization name. */
|
|
42
|
+
export interface OrganizationName {
|
|
43
|
+
/** The original input, verbatim. */
|
|
44
|
+
raw: string
|
|
45
|
+
/** Normalized, designation-stripped key for blocking and comparison. */
|
|
46
|
+
canonical: string
|
|
47
|
+
/** Legal designations that were stripped (`llc`, `inc`, `gmbh`), in encounter order. */
|
|
48
|
+
designations: string[]
|
|
49
|
+
/** The `doing business as` / trade-name clause, canonicalized, when one was present. */
|
|
50
|
+
dba?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A domain pack name. Each protects the abbreviations that are meaningful in that domain from being stripped as legal
|
|
55
|
+
* forms (see {@link DOMAIN_PROTECTED}). `general` protects nothing — the explicit "no domain" choice. Add a pack here
|
|
56
|
+
* (and to {@link DOMAIN_PROTECTED}) per ingest domain.
|
|
57
|
+
*/
|
|
58
|
+
export type DesignationDomain = "general" | "healthcare"
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Context for {@link canonicalizeOrganizationName}. Omit both fields for the universal base behavior.
|
|
62
|
+
*/
|
|
63
|
+
export interface CanonicalizeOptions {
|
|
64
|
+
/**
|
|
65
|
+
* ISO 3166-1 alpha-2 country code of the org's jurisdiction (typically the resolved address country). Adds that
|
|
66
|
+
* country's legal forms — including collision-prone ones gated out of the base — to the strip-set. Case-insensitive;
|
|
67
|
+
* unknown codes add nothing.
|
|
68
|
+
*/
|
|
69
|
+
jurisdiction?: string
|
|
70
|
+
/**
|
|
71
|
+
* Ingest domain. Protects domain-meaningful abbreviations (e.g. `healthcare` protects `pt` / `sca` / `scs`) from
|
|
72
|
+
* being stripped, overriding any jurisdiction pack that would add them.
|
|
73
|
+
*/
|
|
74
|
+
domain?: DesignationDomain
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Universal legal-entity designations — the forms that are safe to strip regardless of jurisdiction or domain because
|
|
79
|
+
* they don't collide with common domain abbreviations. Normalized to lowercase with punctuation removed (so `L.L.C.` →
|
|
80
|
+
* `llc`). Drawn from the ISO 20275 register + `cleanco`'s common set. Stripped as whole tokens wherever they occur.
|
|
81
|
+
* Deliberately excludes name-meaningful words (`group`, `holdings`, `partners`, `associates`) AND the collision-prone
|
|
82
|
+
* forms (`pt`, `sca`, `scs`) — those last live in {@link JURISDICTION_DESIGNATIONS}, gated behind a known
|
|
83
|
+
* jurisdiction.
|
|
84
|
+
*/
|
|
85
|
+
const BASE_DESIGNATIONS = new Set([
|
|
86
|
+
"inc",
|
|
87
|
+
"incorporated",
|
|
88
|
+
"corp",
|
|
89
|
+
"corporation",
|
|
90
|
+
"co",
|
|
91
|
+
"company",
|
|
92
|
+
"llc",
|
|
93
|
+
"lllp",
|
|
94
|
+
"llp",
|
|
95
|
+
"pllc",
|
|
96
|
+
"lp",
|
|
97
|
+
"ltd",
|
|
98
|
+
"limited",
|
|
99
|
+
"plc",
|
|
100
|
+
"pc",
|
|
101
|
+
"pa",
|
|
102
|
+
"ag",
|
|
103
|
+
"sa",
|
|
104
|
+
"sas",
|
|
105
|
+
"sarl",
|
|
106
|
+
"sl",
|
|
107
|
+
"gmbh",
|
|
108
|
+
"mbh",
|
|
109
|
+
"ug",
|
|
110
|
+
"bv",
|
|
111
|
+
"nv",
|
|
112
|
+
"oy",
|
|
113
|
+
"oyj",
|
|
114
|
+
"ab",
|
|
115
|
+
"as",
|
|
116
|
+
"asa",
|
|
117
|
+
"spa",
|
|
118
|
+
"srl",
|
|
119
|
+
"kg",
|
|
120
|
+
"kgaa",
|
|
121
|
+
"kk",
|
|
122
|
+
"pty",
|
|
123
|
+
"proprietary",
|
|
124
|
+
"bhd",
|
|
125
|
+
"sdn",
|
|
126
|
+
"cc",
|
|
127
|
+
"cv",
|
|
128
|
+
"ulc",
|
|
129
|
+
"aps",
|
|
130
|
+
"kft",
|
|
131
|
+
"zrt",
|
|
132
|
+
"doo",
|
|
133
|
+
"ood",
|
|
134
|
+
"ead",
|
|
135
|
+
// Belgian forms — safe to add to the base (no domain collision).
|
|
136
|
+
"bvba",
|
|
137
|
+
"sprl",
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Jurisdiction-gated legal forms (ISO 3166-1 alpha-2 → forms), added only when the jurisdiction is known. This is where
|
|
142
|
+
* the collision-prone tokens live: `pt` (Indonesia), `sca` / `scs` (French/Belgian/Luxembourg commandite forms).
|
|
143
|
+
* Stripping these is correct ONLY when we know the org's country — never in the universal base. Grounded seeds, not
|
|
144
|
+
* exhaustive; extend per ISO 20275.
|
|
145
|
+
*/
|
|
146
|
+
const JURISDICTION_DESIGNATIONS: Record<string, readonly string[]> = {
|
|
147
|
+
ID: ["pt", "tbk", "ud"], // Perseroan Terbatas / Terbuka (listed) / Usaha Dagang
|
|
148
|
+
FR: ["sca", "scs", "sci", "eurl", "sasu", "snc"],
|
|
149
|
+
BE: ["sca", "scs"],
|
|
150
|
+
LU: ["sca", "scs"],
|
|
151
|
+
ES: ["scs"], // Sociedad en Comandita Simple
|
|
152
|
+
IT: ["sapa", "snc"], // S.a.p.a. (commandite par actions) / società in nome collettivo
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Domain protect-sets (domain → tokens never stripped). Overrides any jurisdiction pack: a token here stays in the name
|
|
157
|
+
* even if the org's jurisdiction would treat it as a legal form. `healthcare` guards the clinical abbreviations that
|
|
158
|
+
* collide with gated legal forms — `pt` (Physical Therapy), `sca` (Sudden Cardiac Arrest), `scs` (Spinal Cord
|
|
159
|
+
* Stimulator) — plus a couple of always-clinical ones for future-proofing.
|
|
160
|
+
*/
|
|
161
|
+
const DOMAIN_PROTECTED: Record<DesignationDomain, readonly string[]> = {
|
|
162
|
+
general: [],
|
|
163
|
+
healthcare: ["pt", "sca", "scs", "ot", "dpt"],
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Compute the effective designation strip-set for the given context: `(base ∪ jurisdiction-pack) −
|
|
168
|
+
* domain-protect-pack`. Returns the shared base set unchanged when no context is given (the byte-stable default), so
|
|
169
|
+
* the common path allocates nothing.
|
|
170
|
+
*/
|
|
171
|
+
function resolveDesignations(options?: CanonicalizeOptions): ReadonlySet<string> {
|
|
172
|
+
const jurisdiction = options?.jurisdiction?.trim().toUpperCase()
|
|
173
|
+
const jurisdictionPack = jurisdiction ? JURISDICTION_DESIGNATIONS[jurisdiction] : undefined
|
|
174
|
+
const protectPack = options?.domain ? DOMAIN_PROTECTED[options.domain] : undefined
|
|
175
|
+
|
|
176
|
+
if (!jurisdictionPack && !protectPack?.length) return BASE_DESIGNATIONS
|
|
177
|
+
|
|
178
|
+
const set = new Set(BASE_DESIGNATIONS)
|
|
179
|
+
|
|
180
|
+
if (jurisdictionPack) {
|
|
181
|
+
for (const token of jurisdictionPack) {
|
|
182
|
+
set.add(token)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (protectPack) {
|
|
187
|
+
for (const token of protectPack) {
|
|
188
|
+
set.delete(token)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return set
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Splits a `doing business as` / trade-name clause from a legal name. */
|
|
196
|
+
const DBA_PATTERN = /\s+(?:d\/b\/a|dba|doing business as|t\/a|trading as|a\/k\/a|aka|fka|f\/k\/a)\s+/i
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Canonicalize one name fragment: lowercase, strip accents, connectives → `and`, drop punctuation, remove a leading
|
|
200
|
+
* `the`, strip legal designations, collapse whitespace. Returns the key plus the designations it removed.
|
|
201
|
+
*/
|
|
202
|
+
function canonicalizeFragment(
|
|
203
|
+
fragment: string,
|
|
204
|
+
designationSet: ReadonlySet<string>
|
|
205
|
+
): { canonical: string; designations: string[] } {
|
|
206
|
+
const normalized = fragment
|
|
207
|
+
.normalize("NFKD")
|
|
208
|
+
.replace(/[̀-ͯ]/g, "")
|
|
209
|
+
.toLowerCase()
|
|
210
|
+
// connective punctuation joins words rather than vanishing: "AT&T" → "at and t"
|
|
211
|
+
.replace(/&/g, " and ")
|
|
212
|
+
.replace(/\+/g, " and ")
|
|
213
|
+
// periods + apostrophes are intra-token, so remove (not space): "S.A." → "sa", "Macy's" → "macys"
|
|
214
|
+
.replace(/[.'’]/g, "")
|
|
215
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
216
|
+
.replace(/\s+/g, " ")
|
|
217
|
+
.trim()
|
|
218
|
+
.replace(/^the\s+/, "")
|
|
219
|
+
|
|
220
|
+
const designations: string[] = []
|
|
221
|
+
const kept: string[] = []
|
|
222
|
+
|
|
223
|
+
for (const token of normalized.split(" ")) {
|
|
224
|
+
if (!token) continue
|
|
225
|
+
|
|
226
|
+
if (designationSet.has(token)) {
|
|
227
|
+
designations.push(token)
|
|
228
|
+
} else {
|
|
229
|
+
kept.push(token)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { canonical: kept.join(" "), designations }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Canonicalize an organization name: split off any `doing business as` clause, then reduce the legal name to a
|
|
238
|
+
* designation-stripped key. Returns `null` for empty input.
|
|
239
|
+
*
|
|
240
|
+
* Pass {@link CanonicalizeOptions} to resolve the jurisdiction × domain collision (#668): a `jurisdiction` adds that
|
|
241
|
+
* country's legal forms, a `domain` protects its meaningful abbreviations. With no options the universal base set is
|
|
242
|
+
* used and the result is byte-for-byte the legacy behavior.
|
|
243
|
+
*/
|
|
244
|
+
export function canonicalizeOrganizationName(
|
|
245
|
+
input: string | null | undefined,
|
|
246
|
+
options?: CanonicalizeOptions
|
|
247
|
+
): OrganizationName | null {
|
|
248
|
+
if (typeof input !== "string" || !input.trim()) return null
|
|
249
|
+
|
|
250
|
+
const raw = input
|
|
251
|
+
const designationSet = resolveDesignations(options)
|
|
252
|
+
const [legalPart, ...dbaParts] = input.split(DBA_PATTERN)
|
|
253
|
+
|
|
254
|
+
const { canonical, designations } = canonicalizeFragment(legalPart ?? "", designationSet)
|
|
255
|
+
|
|
256
|
+
const result: OrganizationName = { raw, canonical, designations }
|
|
257
|
+
|
|
258
|
+
if (dbaParts.length) {
|
|
259
|
+
const dba = canonicalizeFragment(dbaParts.join(" "), designationSet).canonical
|
|
260
|
+
|
|
261
|
+
if (dba) {
|
|
262
|
+
result.dba = dba
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return result
|
|
267
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/record",
|
|
3
|
-
"version": "7.1
|
|
3
|
+
"version": "7.2.1",
|
|
4
4
|
"description": "Lean, plain-TypeScript record schema + per-field normalizers for the geocode-first matcher. Address-first: the canonical PostalAddress record composes parser components, the formatter's match key, and a resolved geocode; organization + contact records build on the same canonical record.",
|
|
5
5
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
6
6
|
"repository": {
|
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
"out/**/*.js",
|
|
13
13
|
"out/**/*.js.map",
|
|
14
14
|
"out/**/*.d.ts",
|
|
15
|
-
"out/**/*.d.ts.map"
|
|
15
|
+
"out/**/*.d.ts.map",
|
|
16
|
+
"*.ts",
|
|
17
|
+
"*.tsx",
|
|
18
|
+
"**/*.ts",
|
|
19
|
+
"**/*.tsx",
|
|
20
|
+
"!*.test.ts",
|
|
21
|
+
"!*.test.tsx",
|
|
22
|
+
"!**/*.test.ts",
|
|
23
|
+
"!**/*.test.tsx"
|
|
16
24
|
],
|
|
17
25
|
"type": "module",
|
|
18
26
|
"exports": {
|
|
@@ -35,6 +43,7 @@
|
|
|
35
43
|
}
|
|
36
44
|
},
|
|
37
45
|
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
38
47
|
"exports": {
|
|
39
48
|
"./package.json": "./package.json",
|
|
40
49
|
".": {
|
|
@@ -53,11 +62,10 @@
|
|
|
53
62
|
"types": "./out/organization.d.ts",
|
|
54
63
|
"default": "./out/organization.js"
|
|
55
64
|
}
|
|
56
|
-
}
|
|
57
|
-
"access": "public"
|
|
65
|
+
}
|
|
58
66
|
},
|
|
59
67
|
"dependencies": {
|
|
60
|
-
"@mailwoman/formatter": "7.1
|
|
68
|
+
"@mailwoman/formatter": "7.2.1"
|
|
61
69
|
},
|
|
62
70
|
"devDependencies": {
|
|
63
71
|
"@types/node": ">=26.1.1"
|