@mailwoman/corpus 7.2.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/package.json +59 -27
- package/src/adapter.ts +153 -0
- package/src/adapters/ban/adapter.ts +189 -0
- package/src/adapters/ban/street-decompose.ts +98 -0
- package/src/adapters/fcc-bdc/adapter.ts +188 -0
- package/src/adapters/geonames/adapter.ts +185 -0
- package/src/adapters/geonames-postal/adapter.ts +111 -0
- package/src/adapters/gnaf/adapter.ts +139 -0
- package/src/adapters/gnaf/assemble.ts +200 -0
- package/src/adapters/index.ts +135 -0
- package/src/adapters/openaddresses/adapter.ts +237 -0
- package/src/adapters/overture/adapter.ts +149 -0
- package/src/adapters/state-hi-schools/adapter.ts +171 -0
- package/src/adapters/state-ia-contractors/adapter.ts +148 -0
- package/src/adapters/state-ny-notaries/adapter.ts +169 -0
- package/src/adapters/state-tx-notaries/adapter.ts +160 -0
- package/src/adapters/synth-po-box/adapter.ts +197 -0
- package/src/adapters/tiger/adapter.ts +232 -0
- package/src/adapters/tiger/street-decompose.ts +120 -0
- package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
- package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
- package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
- package/src/adapters/usgov-nad/adapter.ts +338 -0
- package/src/adapters/usgov-nppes/adapter.ts +169 -0
- package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
- package/src/adapters/wof-admin-jp/adapter.ts +176 -0
- package/src/adapters/wof-admin-json/adapter.ts +280 -0
- package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
- package/src/align.ts +317 -0
- package/src/build.ts +348 -0
- package/src/codex/us-fips-state.ts +109 -0
- package/src/format.ts +14 -0
- package/src/golden.ts +147 -0
- package/src/index.ts +28 -0
- package/src/license.ts +48 -0
- package/src/parquet-wrapper/index.ts +9 -0
- package/src/parquet-wrapper/reader.ts +67 -0
- package/src/parquet-wrapper/schema.ts +80 -0
- package/src/parquet-wrapper/writer.ts +102 -0
- package/src/parquet.ts +360 -0
- package/src/runner.ts +283 -0
- package/src/shard-recipes/anchor-absorption.ts +86 -0
- package/src/shard-recipes/boundary-stress.ts +87 -0
- package/src/shard-recipes/country-balanced.ts +471 -0
- package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
- package/src/shard-recipes/fr-admin-split.ts +212 -0
- package/src/shard-recipes/fr-bare-street.ts +85 -0
- package/src/shard-recipes/fr-fragment.ts +304 -0
- package/src/shard-recipes/fr-order.ts +283 -0
- package/src/shard-recipes/german.ts +206 -0
- package/src/shard-recipes/house-venue.ts +78 -0
- package/src/shard-recipes/index.ts +76 -0
- package/src/shard-recipes/intersection.ts +617 -0
- package/src/shard-recipes/locale.ts +415 -0
- package/src/shard-recipes/nl-postcode.ts +105 -0
- package/src/shard-recipes/no-fragment.ts +206 -0
- package/src/shard-recipes/no-street-led.ts +130 -0
- package/src/shard-recipes/no-street.ts +78 -0
- package/src/shard-recipes/po-box-cedex.ts +853 -0
- package/src/shard-recipes/po-box.ts +111 -0
- package/src/shard-recipes/scaffold.ts +187 -0
- package/src/shard-recipes/si-bare-village.ts +88 -0
- package/src/shard-recipes/street-affix.ts +549 -0
- package/src/shard-recipes/street-bare.ts +90 -0
- package/src/shard-recipes/street.ts +74 -0
- package/src/shard-recipes/unit.ts +315 -0
- package/src/split.ts +251 -0
- package/src/synthesize-anchor-absorption.ts +263 -0
- package/src/synthesize-boundary-stress.ts +504 -0
- package/src/synthesize-german.ts +227 -0
- package/src/synthesize-house-venue.ts +192 -0
- package/src/synthesize-intersection.ts +205 -0
- package/src/synthesize-no-street.ts +341 -0
- package/src/synthesize-po-box.ts +307 -0
- package/src/synthesize-street.ts +264 -0
- package/src/synthesize.ts +872 -0
- package/src/tokenize.ts +68 -0
- package/src/tools/align-shard.ts +67 -0
- package/src/tools/audit.ts +352 -0
- package/src/tools/corpus-stats.ts +183 -0
- package/src/tools/fetch/ban.ts +288 -0
- package/src/tools/fetch/download.ts +119 -0
- package/src/tools/fetch/hrsa.ts +65 -0
- package/src/tools/fetch/imls-pls.ts +162 -0
- package/src/tools/fetch/index.ts +121 -0
- package/src/tools/fetch/nad.ts +304 -0
- package/src/tools/fetch/nppes.ts +177 -0
- package/src/tools/fetch/openaddresses.ts +380 -0
- package/src/tools/fetch/state-hi-schools.ts +217 -0
- package/src/tools/fetch/state-sources.ts +142 -0
- package/src/tools/fetch/tiger-full.ts +318 -0
- package/src/tools/golden-expand.ts +524 -0
- package/src/tools/golden-promote.ts +304 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/ingest-csv.ts +420 -0
- package/src/tools/jsonl-to-parquet.ts +200 -0
- package/src/tools/lint-shard-vocab.ts +357 -0
- package/src/tools/lint-shard.ts +502 -0
- package/src/tools/overlay-manifest.ts +141 -0
- package/src/tools/shard-kryptonite.ts +131 -0
- package/src/tools/shard-translit.ts +244 -0
- package/src/types.ts +213 -0
- package/src/wof-json.ts +236 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `usgov-nad`: US DOT National Address Database — ~97M structured address-point records.
|
|
7
|
+
*
|
|
8
|
+
* The single largest US address source available — federal aggregation of state + local 911-grade
|
|
9
|
+
* address points (every addressable location). Compared to TIGER ADDRFEAT (~20M segment-level, no
|
|
10
|
+
* city/locality) and NPPES (~7M provider-centric venues), NAD covers the entire residential +
|
|
11
|
+
* commercial address space with full structured components.
|
|
12
|
+
*
|
|
13
|
+
* The adapter consumes NDJSON shards produced by `fetch-nad.ts`'s featureserver mode (operator
|
|
14
|
+
* pre-downloads via `mailwoman corpus fetch nad`). Each shard is per-OID-range
|
|
15
|
+
* `oids_<start>-<end>.ndjson` with a sibling `.manifest.json`. Adapter iterates every `.ndjson`
|
|
16
|
+
* in the input directory, skipping the `quarantined-bash-bug/` subdir (legacy of the bash-
|
|
17
|
+
* fetcher's silent-page-failure bug).
|
|
18
|
+
*
|
|
19
|
+
* Field mapping (NAD v9 → CanonicalRow components):
|
|
20
|
+
*
|
|
21
|
+
* - House_number: `AddNo_Full` (pre-composed); falls back to AddNum_Pre + Add_Number + AddNum_Suf
|
|
22
|
+
* - Street: `StNam_Full` (pre-composed); falls back to St_PreDir + St_PreTyp + St_Name + St_PosTyp
|
|
23
|
+
*
|
|
24
|
+
* - St_PosDir + St_PosMod composition
|
|
25
|
+
* - Locality: `Post_City` > `Inc_Muni` > `Census_Plc` > `Uninc_Comm` (first non-empty)
|
|
26
|
+
* - Region: `State` (2-char USPS code, including territories: PR, GU, VI, AS, MP)
|
|
27
|
+
* - Postcode: `Zip_Code` + `Plus_4` (joined as `XXXXX-NNNN` when both present)
|
|
28
|
+
* - Venue: `LandmkName` (typically a park, school, hospital, named facility — when present)
|
|
29
|
+
*
|
|
30
|
+
* License: stamped `"Public Domain"` per 17 U.S.C. § 105 (US federal works).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readdir } from "node:fs/promises"
|
|
34
|
+
import { join } from "node:path"
|
|
35
|
+
|
|
36
|
+
import { TextSpliterator } from "spliterator"
|
|
37
|
+
|
|
38
|
+
import { reconcileComponents } from "../../format.ts"
|
|
39
|
+
import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
|
|
40
|
+
|
|
41
|
+
export const USGOV_NAD_ADAPTER_ID = "usgov-nad"
|
|
42
|
+
export const USGOV_NAD_DEFAULT_LICENSE = "Public Domain"
|
|
43
|
+
|
|
44
|
+
interface NADRecord {
|
|
45
|
+
OBJECTID?: number
|
|
46
|
+
UUID?: string | null
|
|
47
|
+
// House number
|
|
48
|
+
AddNum_Pre?: string | null
|
|
49
|
+
Add_Number?: number | string | null
|
|
50
|
+
AddNum_Suf?: string | null
|
|
51
|
+
AddNo_Full?: string | null
|
|
52
|
+
// Street parts
|
|
53
|
+
St_PreMod?: string | null
|
|
54
|
+
St_PreDir?: string | null
|
|
55
|
+
St_PreTyp?: string | null
|
|
56
|
+
St_PreSep?: string | null
|
|
57
|
+
St_Name?: string | null
|
|
58
|
+
St_PosTyp?: string | null
|
|
59
|
+
St_PosDir?: string | null
|
|
60
|
+
St_PosMod?: string | null
|
|
61
|
+
StNam_Full?: string | null
|
|
62
|
+
// Sub-address (carried as part of street for now; Phase 1 has no unit/floor labels)
|
|
63
|
+
Building?: string | null
|
|
64
|
+
Floor?: string | null
|
|
65
|
+
Unit?: string | null
|
|
66
|
+
Room?: string | null
|
|
67
|
+
Seat?: string | null
|
|
68
|
+
Addtl_Loc?: string | null
|
|
69
|
+
SubAddress?: string | null
|
|
70
|
+
// Landmark / venue
|
|
71
|
+
LandmkName?: string | null
|
|
72
|
+
// Locality alternates (we prefer Post_City for what a human would type)
|
|
73
|
+
County?: string | null
|
|
74
|
+
Inc_Muni?: string | null
|
|
75
|
+
Post_City?: string | null
|
|
76
|
+
Census_Plc?: string | null
|
|
77
|
+
Uninc_Comm?: string | null
|
|
78
|
+
Nbrhd_Comm?: string | null
|
|
79
|
+
NatAmArea?: string | null
|
|
80
|
+
NatAmSub?: string | null
|
|
81
|
+
Urbnztn_PR?: string | null
|
|
82
|
+
PlaceOther?: string | null
|
|
83
|
+
PlaceNmTyp?: string | null
|
|
84
|
+
// State + ZIP
|
|
85
|
+
State?: string | null
|
|
86
|
+
Zip_Code?: string | null
|
|
87
|
+
Plus_4?: string | null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const US_STATES_SET = new Set([
|
|
91
|
+
"AL",
|
|
92
|
+
"AK",
|
|
93
|
+
"AZ",
|
|
94
|
+
"AR",
|
|
95
|
+
"CA",
|
|
96
|
+
"CO",
|
|
97
|
+
"CT",
|
|
98
|
+
"DE",
|
|
99
|
+
"DC",
|
|
100
|
+
"FL",
|
|
101
|
+
"GA",
|
|
102
|
+
"HI",
|
|
103
|
+
"ID",
|
|
104
|
+
"IL",
|
|
105
|
+
"IN",
|
|
106
|
+
"IA",
|
|
107
|
+
"KS",
|
|
108
|
+
"KY",
|
|
109
|
+
"LA",
|
|
110
|
+
"ME",
|
|
111
|
+
"MD",
|
|
112
|
+
"MA",
|
|
113
|
+
"MI",
|
|
114
|
+
"MN",
|
|
115
|
+
"MS",
|
|
116
|
+
"MO",
|
|
117
|
+
"MT",
|
|
118
|
+
"NE",
|
|
119
|
+
"NV",
|
|
120
|
+
"NH",
|
|
121
|
+
"NJ",
|
|
122
|
+
"NM",
|
|
123
|
+
"NY",
|
|
124
|
+
"NC",
|
|
125
|
+
"ND",
|
|
126
|
+
"OH",
|
|
127
|
+
"OK",
|
|
128
|
+
"OR",
|
|
129
|
+
"PA",
|
|
130
|
+
"RI",
|
|
131
|
+
"SC",
|
|
132
|
+
"SD",
|
|
133
|
+
"TN",
|
|
134
|
+
"TX",
|
|
135
|
+
"UT",
|
|
136
|
+
"VT",
|
|
137
|
+
"VA",
|
|
138
|
+
"WA",
|
|
139
|
+
"WV",
|
|
140
|
+
"WI",
|
|
141
|
+
"WY",
|
|
142
|
+
// Territories that ship in NAD
|
|
143
|
+
"PR",
|
|
144
|
+
"GU",
|
|
145
|
+
"VI",
|
|
146
|
+
"AS",
|
|
147
|
+
"MP",
|
|
148
|
+
])
|
|
149
|
+
|
|
150
|
+
function nonEmpty(...values: Array<string | null | undefined>): string | undefined {
|
|
151
|
+
for (const v of values) {
|
|
152
|
+
const trimmed = (v ?? "").toString().trim()
|
|
153
|
+
|
|
154
|
+
if (trimmed) return trimmed
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function composeHouseNumber(r: NADRecord): string | undefined {
|
|
161
|
+
const full = (r.AddNo_Full ?? "").toString().trim()
|
|
162
|
+
|
|
163
|
+
if (full) return full
|
|
164
|
+
const num = r.Add_Number == null ? "" : String(r.Add_Number).trim()
|
|
165
|
+
|
|
166
|
+
if (!num) return undefined
|
|
167
|
+
const pre = (r.AddNum_Pre ?? "").toString().trim()
|
|
168
|
+
const suf = (r.AddNum_Suf ?? "").toString().trim()
|
|
169
|
+
|
|
170
|
+
return [pre, num, suf].filter(Boolean).join(" ").trim() || undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface DecomposedNADStreet {
|
|
174
|
+
prefix?: string
|
|
175
|
+
street?: string
|
|
176
|
+
suffix?: string
|
|
177
|
+
full: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function decomposeNADStreet(r: NADRecord): DecomposedNADStreet | undefined {
|
|
181
|
+
const name = (r.St_Name ?? "").toString().trim()
|
|
182
|
+
|
|
183
|
+
if (name) {
|
|
184
|
+
const preDir = (r.St_PreDir ?? "").toString().trim()
|
|
185
|
+
const preTyp = (r.St_PreTyp ?? "").toString().trim()
|
|
186
|
+
const preSep = (r.St_PreSep ?? "").toString().trim()
|
|
187
|
+
const posTyp = (r.St_PosTyp ?? "").toString().trim()
|
|
188
|
+
const posDir = (r.St_PosDir ?? "").toString().trim()
|
|
189
|
+
const prefix = [preDir, preTyp, preSep].filter(Boolean).join(" ") || undefined
|
|
190
|
+
const suffix = [posTyp, posDir].filter(Boolean).join(" ") || undefined
|
|
191
|
+
const full = [prefix, name, suffix].filter(Boolean).join(" ")
|
|
192
|
+
|
|
193
|
+
return { prefix, street: name, suffix, full }
|
|
194
|
+
}
|
|
195
|
+
const full = (r.StNam_Full ?? "").toString().trim()
|
|
196
|
+
|
|
197
|
+
if (full) return { full, street: full }
|
|
198
|
+
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function composeLocality(r: NADRecord): string | undefined {
|
|
203
|
+
return nonEmpty(r.Post_City, r.Inc_Muni, r.Census_Plc, r.Uninc_Comm)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function composePostcode(r: NADRecord): string | undefined {
|
|
207
|
+
const zip = (r.Zip_Code ?? "").toString().trim()
|
|
208
|
+
|
|
209
|
+
if (!zip) return undefined
|
|
210
|
+
const plus4 = (r.Plus_4 ?? "").toString().trim()
|
|
211
|
+
|
|
212
|
+
return plus4 ? `${zip}-${plus4}` : zip
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function composeRaw(parts: {
|
|
216
|
+
venue?: string
|
|
217
|
+
houseNumber?: string
|
|
218
|
+
street?: string
|
|
219
|
+
unit?: string
|
|
220
|
+
locality: string
|
|
221
|
+
region: string
|
|
222
|
+
postcode: string
|
|
223
|
+
}): string {
|
|
224
|
+
const streetLine = [parts.houseNumber, parts.street, parts.unit].filter(Boolean).join(" ").trim()
|
|
225
|
+
const tail = `${parts.locality}, ${parts.region} ${parts.postcode}`
|
|
226
|
+
|
|
227
|
+
return [parts.venue, streetLine || undefined, tail].filter(Boolean).join(", ")
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function createUsgovNADAdapter(): CorpusAdapter {
|
|
231
|
+
return {
|
|
232
|
+
id: USGOV_NAD_ADAPTER_ID,
|
|
233
|
+
defaultLicense: USGOV_NAD_DEFAULT_LICENSE,
|
|
234
|
+
description:
|
|
235
|
+
"US DOT National Address Database — ~97M structured US address points (911-grade). Single largest US source.",
|
|
236
|
+
|
|
237
|
+
async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
|
|
238
|
+
if (opts.country && opts.country !== "US") {
|
|
239
|
+
throw new Error(`usgov-nad adapter: only US supported, got country=${opts.country}`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// inputPath is a directory of NDJSON shards (per fetch-nad.ts featureserver output).
|
|
243
|
+
// Single-file inputs (e.g. a bulk-extracted CSV) are not currently supported — the
|
|
244
|
+
// featureserver shard pattern is the primary distribution.
|
|
245
|
+
const entries = await readdir(opts.inputPath)
|
|
246
|
+
const shards = entries.filter((n) => n.endsWith(".ndjson")).sort()
|
|
247
|
+
|
|
248
|
+
let emitted = 0
|
|
249
|
+
outer: for (const shard of shards) {
|
|
250
|
+
if (opts.signal?.aborted) break
|
|
251
|
+
// TextSpliterator streams string lines; the per-line try/catch below keeps the reader
|
|
252
|
+
// tolerant of malformed rows (skip silently), so TextSpliterator + explicit JSON.parse —
|
|
253
|
+
// not JSONSpliterator, which would throw. The path string lets the lib own + dispose each
|
|
254
|
+
// shard's file handle, including on the `break outer` early exit.
|
|
255
|
+
const lines = TextSpliterator.fromAsync(join(opts.inputPath, shard))
|
|
256
|
+
|
|
257
|
+
for await (const line of lines) {
|
|
258
|
+
if (opts.signal?.aborted) break outer
|
|
259
|
+
|
|
260
|
+
if (opts.limit !== undefined && emitted >= opts.limit) break outer
|
|
261
|
+
|
|
262
|
+
if (!line) continue
|
|
263
|
+
|
|
264
|
+
let record: NADRecord
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
record = JSON.parse(line) as NADRecord
|
|
268
|
+
} catch {
|
|
269
|
+
continue // malformed line — skip silently
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const state = (record.State ?? "").toString().trim().toUpperCase()
|
|
273
|
+
|
|
274
|
+
if (!US_STATES_SET.has(state)) continue
|
|
275
|
+
|
|
276
|
+
const locality = composeLocality(record)
|
|
277
|
+
|
|
278
|
+
if (!locality) continue
|
|
279
|
+
|
|
280
|
+
const postcode = composePostcode(record)
|
|
281
|
+
|
|
282
|
+
if (!postcode) continue
|
|
283
|
+
|
|
284
|
+
const decomposed = decomposeNADStreet(record)
|
|
285
|
+
const houseNumber = composeHouseNumber(record)
|
|
286
|
+
const venue = nonEmpty(record.LandmkName)
|
|
287
|
+
const unit = nonEmpty(record.Unit, record.Building, record.Floor, record.Room)
|
|
288
|
+
|
|
289
|
+
const components: CanonicalRow["components"] = {
|
|
290
|
+
...(venue ? { venue } : {}),
|
|
291
|
+
...(houseNumber ? { house_number: houseNumber } : {}),
|
|
292
|
+
...(decomposed?.prefix ? { street_prefix: decomposed.prefix } : {}),
|
|
293
|
+
...(decomposed?.street ? { street: decomposed.street } : {}),
|
|
294
|
+
...(decomposed?.suffix ? { street_suffix: decomposed.suffix } : {}),
|
|
295
|
+
...(unit ? { unit } : {}),
|
|
296
|
+
locality,
|
|
297
|
+
region: state,
|
|
298
|
+
postcode,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const raw = composeRaw({
|
|
302
|
+
venue,
|
|
303
|
+
houseNumber,
|
|
304
|
+
street: decomposed?.full,
|
|
305
|
+
unit,
|
|
306
|
+
locality,
|
|
307
|
+
region: state,
|
|
308
|
+
postcode,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
if (!raw) continue
|
|
312
|
+
|
|
313
|
+
const aligned = reconcileComponents(components, raw)
|
|
314
|
+
|
|
315
|
+
if (Object.keys(aligned).length <= 2) continue
|
|
316
|
+
|
|
317
|
+
const sourceID = record.UUID
|
|
318
|
+
? `${USGOV_NAD_ADAPTER_ID}-${record.UUID}`
|
|
319
|
+
: `${USGOV_NAD_ADAPTER_ID}-${record.OBJECTID ?? `${shard}:${emitted}`}`
|
|
320
|
+
|
|
321
|
+
yield {
|
|
322
|
+
raw,
|
|
323
|
+
components: aligned,
|
|
324
|
+
country: "US",
|
|
325
|
+
locale: "en-US",
|
|
326
|
+
source: USGOV_NAD_ADAPTER_ID,
|
|
327
|
+
source_id: sourceID,
|
|
328
|
+
corpus_version: "",
|
|
329
|
+
license: USGOV_NAD_DEFAULT_LICENSE,
|
|
330
|
+
}
|
|
331
|
+
emitted++
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export const usgovNADAdapter = createUsgovNADAdapter()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `usgov-nppes`: CMS National Plan and Provider Enumeration System (NPI registry) CSV consumer.
|
|
7
|
+
*
|
|
8
|
+
* NPPES is the authoritative US healthcare provider registry, published monthly by CMS. Each row
|
|
9
|
+
* carries a provider's business practice location address together with their legal business name
|
|
10
|
+
* or individual name. At ~7M rows it is the single largest venue+address signal source
|
|
11
|
+
* available.
|
|
12
|
+
*
|
|
13
|
+
* The adapter consumes the monthly full-replacement CSV (operator pre-downloads via
|
|
14
|
+
* `fetch-nppes.ts`). Column names match the canonical NPPES "Full Replacement Monthly NPI File"
|
|
15
|
+
* header published at `https://download.cms.gov/nppes/NPI_Files.html`.
|
|
16
|
+
*
|
|
17
|
+
* Output: one row per CSV record where the practice location address is populated. Organization
|
|
18
|
+
* rows carry `venue` from the legal business name; individual rows compose `attention` from
|
|
19
|
+
* last+first name. Address quad goes on `(house_number, street, locality, region, postcode)`.
|
|
20
|
+
*
|
|
21
|
+
* License: stamped `"Public Domain"` per CMS's federal government distribution terms.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createReadStream } from "node:fs"
|
|
25
|
+
|
|
26
|
+
import { parse as csvParse } from "csv-parse"
|
|
27
|
+
|
|
28
|
+
import { stableSourceID } from "../../adapter.ts"
|
|
29
|
+
import { lookupStateAbbreviation } from "../../codex/us-fips-state.ts"
|
|
30
|
+
import { reconcileComponents } from "../../format.ts"
|
|
31
|
+
import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
|
|
32
|
+
|
|
33
|
+
export const USGOV_NPPES_ADAPTER_ID = "usgov-nppes"
|
|
34
|
+
export const USGOV_NPPES_DEFAULT_LICENSE = "Public Domain"
|
|
35
|
+
|
|
36
|
+
const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
|
|
37
|
+
|
|
38
|
+
interface NPPESRow {
|
|
39
|
+
NPI: string
|
|
40
|
+
"Entity Type Code": string
|
|
41
|
+
"Provider Organization Name (Legal Business Name)": string
|
|
42
|
+
"Provider Last Name (Legal Name)": string
|
|
43
|
+
"Provider First Name": string
|
|
44
|
+
"Provider First Line Business Practice Location Address": string
|
|
45
|
+
"Provider Second Line Business Practice Location Address": string
|
|
46
|
+
"Provider Business Practice Location Address City Name": string
|
|
47
|
+
"Provider Business Practice Location Address State Name": string
|
|
48
|
+
"Provider Business Practice Location Address Postal Code": string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function splitAddress(address: string): { house_number?: string; street: string } | null {
|
|
52
|
+
const trimmed = address.trim()
|
|
53
|
+
|
|
54
|
+
if (!trimmed) return null
|
|
55
|
+
const m = HOUSE_NUMBER_PREFIX.exec(trimmed)
|
|
56
|
+
|
|
57
|
+
if (m) return { house_number: m[1], street: m[2]!.trim() }
|
|
58
|
+
|
|
59
|
+
return { street: trimmed }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function composeRaw(
|
|
63
|
+
venue: string | undefined,
|
|
64
|
+
house: string | undefined,
|
|
65
|
+
street: string,
|
|
66
|
+
city: string,
|
|
67
|
+
state: string,
|
|
68
|
+
postcode: string
|
|
69
|
+
): string {
|
|
70
|
+
const streetPart = [house, street].filter(Boolean).join(" ").trim()
|
|
71
|
+
const cityPart = [city.trim(), [state, postcode].filter(Boolean).join(" ").trim()].filter(Boolean).join(", ")
|
|
72
|
+
|
|
73
|
+
return [venue, streetPart, cityPart].filter(Boolean).join(", ")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createUsgovNPPESAdapter(): CorpusAdapter {
|
|
77
|
+
return {
|
|
78
|
+
id: USGOV_NPPES_ADAPTER_ID,
|
|
79
|
+
defaultLicense: USGOV_NPPES_DEFAULT_LICENSE,
|
|
80
|
+
description:
|
|
81
|
+
"CMS National Plan and Provider Enumeration System — 7M provider practice locations (public-domain). Venue+address co-occurrence at scale.",
|
|
82
|
+
|
|
83
|
+
async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
|
|
84
|
+
if (opts.country && opts.country !== "US") {
|
|
85
|
+
throw new Error(`usgov-nppes adapter: only US supported, got country=${opts.country}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
|
|
89
|
+
const parser = stream.pipe(
|
|
90
|
+
csvParse({
|
|
91
|
+
columns: true,
|
|
92
|
+
skip_empty_lines: true,
|
|
93
|
+
relax_quotes: true,
|
|
94
|
+
relax_column_count: true,
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
let emitted = 0
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
for await (const record of parser as AsyncIterable<NPPESRow>) {
|
|
102
|
+
if (opts.signal?.aborted) break
|
|
103
|
+
|
|
104
|
+
if (opts.limit !== undefined && emitted >= opts.limit) break
|
|
105
|
+
|
|
106
|
+
const npi = (record.NPI ?? "").trim()
|
|
107
|
+
const entityType = (record["Entity Type Code"] ?? "").trim()
|
|
108
|
+
const orgName = (record["Provider Organization Name (Legal Business Name)"] ?? "").trim()
|
|
109
|
+
const lastName = (record["Provider Last Name (Legal Name)"] ?? "").trim()
|
|
110
|
+
const firstName = (record["Provider First Name"] ?? "").trim()
|
|
111
|
+
|
|
112
|
+
const address1 = (record["Provider First Line Business Practice Location Address"] ?? "").trim()
|
|
113
|
+
const address2 = (record["Provider Second Line Business Practice Location Address"] ?? "").trim()
|
|
114
|
+
const city = (record["Provider Business Practice Location Address City Name"] ?? "").trim()
|
|
115
|
+
const stateRaw = (record["Provider Business Practice Location Address State Name"] ?? "").trim()
|
|
116
|
+
const postcode = (record["Provider Business Practice Location Address Postal Code"] ?? "").trim()
|
|
117
|
+
|
|
118
|
+
if (!city || !postcode) continue
|
|
119
|
+
|
|
120
|
+
const state = lookupStateAbbreviation(stateRaw)
|
|
121
|
+
|
|
122
|
+
if (!state) continue
|
|
123
|
+
|
|
124
|
+
const fullStreet = [address1, address2].filter(Boolean).join(" ")
|
|
125
|
+
const split = splitAddress(fullStreet)
|
|
126
|
+
|
|
127
|
+
if (!split) continue
|
|
128
|
+
|
|
129
|
+
const venue = orgName || [firstName, lastName].filter(Boolean).join(" ") || undefined
|
|
130
|
+
|
|
131
|
+
const components: CanonicalRow["components"] = {
|
|
132
|
+
...(venue ? { venue } : {}),
|
|
133
|
+
...(split.house_number ? { house_number: split.house_number } : {}),
|
|
134
|
+
street: split.street,
|
|
135
|
+
locality: city,
|
|
136
|
+
region: state.abbreviation,
|
|
137
|
+
postcode,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const raw = composeRaw(venue, split.house_number, split.street, city, state.abbreviation, postcode)
|
|
141
|
+
|
|
142
|
+
if (!raw) continue
|
|
143
|
+
|
|
144
|
+
const aligned = reconcileComponents(components, raw)
|
|
145
|
+
|
|
146
|
+
if (Object.keys(aligned).length <= 2) continue
|
|
147
|
+
|
|
148
|
+
const sourceID = npi ? `${USGOV_NPPES_ADAPTER_ID}-${npi}` : stableSourceID(USGOV_NPPES_ADAPTER_ID, aligned)
|
|
149
|
+
|
|
150
|
+
yield {
|
|
151
|
+
raw,
|
|
152
|
+
components: aligned,
|
|
153
|
+
country: "US",
|
|
154
|
+
locale: "en-US",
|
|
155
|
+
source: USGOV_NPPES_ADAPTER_ID,
|
|
156
|
+
source_id: sourceID,
|
|
157
|
+
corpus_version: "",
|
|
158
|
+
license: USGOV_NPPES_DEFAULT_LICENSE,
|
|
159
|
+
}
|
|
160
|
+
emitted++
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
stream.destroy()
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const usgovNPPESAdapter = createUsgovNPPESAdapter()
|