@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.
Files changed (103) hide show
  1. package/package.json +59 -27
  2. package/src/adapter.ts +153 -0
  3. package/src/adapters/ban/adapter.ts +189 -0
  4. package/src/adapters/ban/street-decompose.ts +98 -0
  5. package/src/adapters/fcc-bdc/adapter.ts +188 -0
  6. package/src/adapters/geonames/adapter.ts +185 -0
  7. package/src/adapters/geonames-postal/adapter.ts +111 -0
  8. package/src/adapters/gnaf/adapter.ts +139 -0
  9. package/src/adapters/gnaf/assemble.ts +200 -0
  10. package/src/adapters/index.ts +135 -0
  11. package/src/adapters/openaddresses/adapter.ts +237 -0
  12. package/src/adapters/overture/adapter.ts +149 -0
  13. package/src/adapters/state-hi-schools/adapter.ts +171 -0
  14. package/src/adapters/state-ia-contractors/adapter.ts +148 -0
  15. package/src/adapters/state-ny-notaries/adapter.ts +169 -0
  16. package/src/adapters/state-tx-notaries/adapter.ts +160 -0
  17. package/src/adapters/synth-po-box/adapter.ts +197 -0
  18. package/src/adapters/tiger/adapter.ts +232 -0
  19. package/src/adapters/tiger/street-decompose.ts +120 -0
  20. package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
  21. package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
  22. package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
  23. package/src/adapters/usgov-nad/adapter.ts +338 -0
  24. package/src/adapters/usgov-nppes/adapter.ts +169 -0
  25. package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
  26. package/src/adapters/wof-admin-jp/adapter.ts +176 -0
  27. package/src/adapters/wof-admin-json/adapter.ts +280 -0
  28. package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
  29. package/src/align.ts +317 -0
  30. package/src/build.ts +348 -0
  31. package/src/codex/us-fips-state.ts +109 -0
  32. package/src/format.ts +14 -0
  33. package/src/golden.ts +147 -0
  34. package/src/index.ts +28 -0
  35. package/src/license.ts +48 -0
  36. package/src/parquet-wrapper/index.ts +9 -0
  37. package/src/parquet-wrapper/reader.ts +67 -0
  38. package/src/parquet-wrapper/schema.ts +80 -0
  39. package/src/parquet-wrapper/writer.ts +102 -0
  40. package/src/parquet.ts +360 -0
  41. package/src/runner.ts +283 -0
  42. package/src/shard-recipes/anchor-absorption.ts +86 -0
  43. package/src/shard-recipes/boundary-stress.ts +87 -0
  44. package/src/shard-recipes/country-balanced.ts +471 -0
  45. package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
  46. package/src/shard-recipes/fr-admin-split.ts +212 -0
  47. package/src/shard-recipes/fr-bare-street.ts +85 -0
  48. package/src/shard-recipes/fr-fragment.ts +304 -0
  49. package/src/shard-recipes/fr-order.ts +283 -0
  50. package/src/shard-recipes/german.ts +206 -0
  51. package/src/shard-recipes/house-venue.ts +78 -0
  52. package/src/shard-recipes/index.ts +76 -0
  53. package/src/shard-recipes/intersection.ts +617 -0
  54. package/src/shard-recipes/locale.ts +415 -0
  55. package/src/shard-recipes/nl-postcode.ts +105 -0
  56. package/src/shard-recipes/no-fragment.ts +206 -0
  57. package/src/shard-recipes/no-street-led.ts +130 -0
  58. package/src/shard-recipes/no-street.ts +78 -0
  59. package/src/shard-recipes/po-box-cedex.ts +853 -0
  60. package/src/shard-recipes/po-box.ts +111 -0
  61. package/src/shard-recipes/scaffold.ts +187 -0
  62. package/src/shard-recipes/si-bare-village.ts +88 -0
  63. package/src/shard-recipes/street-affix.ts +549 -0
  64. package/src/shard-recipes/street-bare.ts +90 -0
  65. package/src/shard-recipes/street.ts +74 -0
  66. package/src/shard-recipes/unit.ts +315 -0
  67. package/src/split.ts +251 -0
  68. package/src/synthesize-anchor-absorption.ts +263 -0
  69. package/src/synthesize-boundary-stress.ts +504 -0
  70. package/src/synthesize-german.ts +227 -0
  71. package/src/synthesize-house-venue.ts +192 -0
  72. package/src/synthesize-intersection.ts +205 -0
  73. package/src/synthesize-no-street.ts +341 -0
  74. package/src/synthesize-po-box.ts +307 -0
  75. package/src/synthesize-street.ts +264 -0
  76. package/src/synthesize.ts +872 -0
  77. package/src/tokenize.ts +68 -0
  78. package/src/tools/align-shard.ts +67 -0
  79. package/src/tools/audit.ts +352 -0
  80. package/src/tools/corpus-stats.ts +183 -0
  81. package/src/tools/fetch/ban.ts +288 -0
  82. package/src/tools/fetch/download.ts +119 -0
  83. package/src/tools/fetch/hrsa.ts +65 -0
  84. package/src/tools/fetch/imls-pls.ts +162 -0
  85. package/src/tools/fetch/index.ts +121 -0
  86. package/src/tools/fetch/nad.ts +304 -0
  87. package/src/tools/fetch/nppes.ts +177 -0
  88. package/src/tools/fetch/openaddresses.ts +380 -0
  89. package/src/tools/fetch/state-hi-schools.ts +217 -0
  90. package/src/tools/fetch/state-sources.ts +142 -0
  91. package/src/tools/fetch/tiger-full.ts +318 -0
  92. package/src/tools/golden-expand.ts +524 -0
  93. package/src/tools/golden-promote.ts +304 -0
  94. package/src/tools/index.ts +24 -0
  95. package/src/tools/ingest-csv.ts +420 -0
  96. package/src/tools/jsonl-to-parquet.ts +200 -0
  97. package/src/tools/lint-shard-vocab.ts +357 -0
  98. package/src/tools/lint-shard.ts +502 -0
  99. package/src/tools/overlay-manifest.ts +141 -0
  100. package/src/tools/shard-kryptonite.ts +131 -0
  101. package/src/tools/shard-translit.ts +244 -0
  102. package/src/types.ts +213 -0
  103. package/src/wof-json.ts +236 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The `mailwoman corpus fetch <source>` family — reproducible bulk-download recovery modules for
7
+ * the open-data sources the corpus build pipeline consumes. Each module writes the raw download
8
+ * files **plus** a sibling `MANIFEST.json` capturing the origin URL, fetch timestamp, byte count,
9
+ * and sha256 so downstream adapters can verify provenance.
10
+ *
11
+ * The corpus build pipeline itself does NOT call these modules — the existing convention is for
12
+ * operators to pre-download into an out-root and point adapters at the resulting files. These
13
+ * modules exist for **reproducibility** (disk-loss recovery, weekly refresh, fresh-environment
14
+ * bootstrap).
15
+ *
16
+ * ## Usage
17
+ *
18
+ * ```sh
19
+ * # Default: writes under ./data/corpus/sources/ relative to the working directory
20
+ * mailwoman corpus fetch state-sources
21
+ * mailwoman corpus fetch hrsa
22
+ *
23
+ * # Or point at the standard mailwoman data root
24
+ * mailwoman corpus fetch state-sources --out-root /data/corpus/sources
25
+ * ```
26
+ *
27
+ * Each adapter under `corpus/src/adapters/<adapter>/README.md` documents the specific URL its
28
+ * input was pulled from; these modules mirror those URLs in a single executable place.
29
+ *
30
+ * ## Coverage
31
+ *
32
+ * - `ban` — French BAN (Base Adresse Nationale), all départements incl. DOM/TOM. Tier B (Licence
33
+ * Ouverte 2.0).
34
+ * - `nad` — US DOT National Address Database (~97M address points, ArcGIS FeatureServer). Tier A
35
+ * (US PD).
36
+ * - `hrsa` — HRSA Health Center Service Delivery Sites (federal). Tier A (US PD).
37
+ * - `imls-pls` — IMLS Public Libraries Survey, outlet-level (~17K library branches, FY 2023).
38
+ * Tier A (US PD).
39
+ * - `nppes` — NPPES NPI registry, full monthly dissemination (~7M provider venue+address rows).
40
+ * Tier A (US PD).
41
+ * - `openaddresses` — OpenAddresses country collections (default: Canada / `ca`). Tier B/C mixed
42
+ * — per-row filter.
43
+ * - `state-sources` — NY/TX/DE/OR notaries, IA contractors, WA health providers, HI lobbyists.
44
+ * Tier A (state PD-equivalent).
45
+ * - `state-hi-schools` — Hawaii DOE school directory (XLSX → CSV via openpyxl). Tier A (state
46
+ * PD-equivalent).
47
+ * - `tiger-full` — US Census TIGER 2024 ADDRFEAT, all US counties. Tier A (US PD).
48
+ *
49
+ * License tiers per `docs/licensing-strategy.md` (or the playpen knowledge base mirror at
50
+ * `docs/docs/projects/mailwoman/licensing-strategy.md`). `openaddresses` is a **Tier-mixed**
51
+ * source: the downloaded collection includes CC0, CC-BY, OGL, and ODbL/CC-BY-SA rows. The per-row
52
+ * `LICENSE` filter in the `openaddresses` adapter is essential — Tier-C (ODbL, CC-BY-SA) rows are
53
+ * dropped at ingest by default to protect proprietary-weights training.
54
+ *
55
+ * ### OpenAddresses authentication (as of 2026-05-18)
56
+ *
57
+ * `batch.openaddresses.io` now requires a free registered account for bulk downloads (auth gate
58
+ * prevents CDN abuse; data remains openly licensed). `fetchOpenAddresses` reads `OA_BATCH_TOKEN`
59
+ * from the environment:
60
+ *
61
+ * ```sh
62
+ * # One-time: register at https://batch.openaddresses.io/register
63
+ * # Log in → Profile → "Create Token" → copy token
64
+ * export OA_BATCH_TOKEN=<your-token>
65
+ *
66
+ * # Download Canada (~2 GiB compressed, ~7 GiB uncompressed)
67
+ * mailwoman corpus fetch openaddresses --country ca \
68
+ * --out-root /mnt/playpen/mailwoman-data/corpus/sources
69
+ *
70
+ * # Or any other OA country code
71
+ * mailwoman corpus fetch openaddresses --country fr
72
+ * ```
73
+ *
74
+ * Without a token the command prints setup instructions and reports the failure.
75
+ *
76
+ * ## Adding a new source
77
+ *
78
+ * 1. Pick the right module (or create a sibling one if the source is from a meaningfully
79
+ * different family).
80
+ * 2. Append to the `SOURCES` array: `{ slug, filename, url }`.
81
+ * 3. Confirm the destination URL via `curl -sI -L <url> | head` before committing — state
82
+ * open-data portals occasionally rotate Socrata view IDs.
83
+ * 4. Run the command against a scratch `--out-root` to verify the download succeeds + the
84
+ * MANIFEST is well-formed.
85
+ * 5. Add the source's adapter (or extend an existing one) under `corpus/src/adapters/`.
86
+ */
87
+
88
+ import { fetchBan } from "./ban.ts"
89
+ import { fetchHRSA } from "./hrsa.ts"
90
+ import { fetchIMLSPLS } from "./imls-pls.ts"
91
+ import { fetchNAD } from "./nad.ts"
92
+ import { fetchNPPES } from "./nppes.ts"
93
+ import { fetchOpenAddresses } from "./openaddresses.ts"
94
+ import { fetchStateHISchools } from "./state-hi-schools.ts"
95
+ import { fetchStateSources } from "./state-sources.ts"
96
+ import { fetchTigerFull } from "./tiger-full.ts"
97
+
98
+ export * from "./ban.ts"
99
+ export * from "./hrsa.ts"
100
+ export * from "./imls-pls.ts"
101
+ export * from "./nad.ts"
102
+ export * from "./nppes.ts"
103
+ export * from "./openaddresses.ts"
104
+ export * from "./state-hi-schools.ts"
105
+ export * from "./state-sources.ts"
106
+ export * from "./tiger-full.ts"
107
+
108
+ /** The fetch-source registry: id → module entry point. Each entry point takes its own options interface. */
109
+ export const FETCH_SOURCES = {
110
+ ban: fetchBan,
111
+ nad: fetchNAD,
112
+ hrsa: fetchHRSA,
113
+ "imls-pls": fetchIMLSPLS,
114
+ nppes: fetchNPPES,
115
+ openaddresses: fetchOpenAddresses,
116
+ "state-sources": fetchStateSources,
117
+ "state-hi-schools": fetchStateHISchools,
118
+ "tiger-full": fetchTigerFull,
119
+ } as const
120
+
121
+ export type FetchSourceID = keyof typeof FETCH_SOURCES
@@ -0,0 +1,304 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Fetch the US DOT National Address Database (NAD) — ~97 million structured US address-point
7
+ * records aggregated from state and local authorities. Source for the `usgov-nad` adapter (#30).
8
+ * US Public Domain (17 U.S.C. § 105).
9
+ *
10
+ * - Bounded per-chunk page concurrency (4× speedup at safe pressure)
11
+ * - 5× larger page size (5 000 vs. the old bash fetcher's 2 000) — fewer round-trips per chunk
12
+ * - Honest `complete: true` flag: only set when every page in the chunk fetched cleanly
13
+ * - Built-in fetch with gzip/brotli decompression (no curl + Python subprocess tax)
14
+ * - Per-chunk manifest with sha256 + record count + error count
15
+ *
16
+ * ## Source layout
17
+ *
18
+ * The ArcGIS FeatureServer is the only fully-automated path. As of 2026-05:
19
+ *
20
+ * - **`bulk`** mode requires a pre-signed S3 URL (Akamai blocks scripted curl on the DOT page). Pass
21
+ * `--nad-url <presigned>` from a browser visit to
22
+ * [https://www.transportation.gov/gis/national-address-database](https://www.transportation.gov/gis/national-address-database).
23
+ * - **`featureserver`** mode (default) pages the live FeatureService via OBJECTID ranges, writing
24
+ * NDJSON chunks into `<outRoot>/usgov-nad/featureserver/`.
25
+ *
26
+ * ## Usage
27
+ *
28
+ * ```sh
29
+ * mailwoman corpus fetch nad --out-root /mnt/playpen/mailwoman-data/corpus/sources
30
+ *
31
+ * # Resume from an OID
32
+ * mailwoman corpus fetch nad --start-oid 34400001
33
+ *
34
+ * # Increase concurrency on a fast link
35
+ * mailwoman corpus fetch nad --concurrency 8 --page-size 10000
36
+ * ```
37
+ */
38
+
39
+ import { existsSync, mkdirSync, statSync } from "node:fs"
40
+ import { writeFile } from "node:fs/promises"
41
+ import { join } from "node:path"
42
+
43
+ import { sha256File } from "@mailwoman/core/utils"
44
+
45
+ import type { BaseFetchOptions, FetchSummary } from "./download.ts"
46
+ import { downloadToFile, readManifest, writeManifest } from "./download.ts"
47
+
48
+ const SLUG = "usgov-nad"
49
+ const FEATURE_SERVICE_URL =
50
+ "https://services.arcgis.com/xOi1kZaI0eWDREZv/ArcGIS/rest/services/Address_Points_from_National_Address_Database_view/FeatureServer/0"
51
+
52
+ export interface FetchNADOptions extends BaseFetchOptions {
53
+ /** Fetch strategy. Default `featureserver`. */
54
+ mode?: "featureserver" | "bulk"
55
+ /** Pre-signed S3 URL for bulk mode. */
56
+ nadURL?: string
57
+ /** Records per output file. Default `100000`. */
58
+ chunkSize?: number
59
+ /** Records per HTTP request. Default `5000`. */
60
+ pageSize?: number
61
+ /** Parallel page fetches within a chunk. Default `4`. */
62
+ concurrency?: number
63
+ /** Start OBJECTID. Default `1`. */
64
+ startOID?: number
65
+ /** Stop before this OID. Default = total count. */
66
+ endOID?: number
67
+ }
68
+
69
+ interface ChunkManifest {
70
+ source_url: string
71
+ oid_range: [number, number]
72
+ downloaded_at: string
73
+ filename: string
74
+ sha256: string
75
+ bytes: number
76
+ record_count: number
77
+ page_errors: number
78
+ complete: boolean
79
+ }
80
+
81
+ async function fetchPage(startOID: number, endOID: number, pageSize: number): Promise<unknown[]> {
82
+ const url = new URL(`${FEATURE_SERVICE_URL}/query`)
83
+ url.searchParams.set("where", `OBJECTID BETWEEN ${startOID} AND ${endOID}`)
84
+ url.searchParams.set("outFields", "*")
85
+ url.searchParams.set("f", "json")
86
+ url.searchParams.set("resultRecordCount", String(pageSize))
87
+
88
+ const res = await fetch(url, {
89
+ headers: { "Accept-Encoding": "gzip, br" },
90
+ signal: AbortSignal.timeout(120_000),
91
+ })
92
+
93
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} on OID ${startOID}-${endOID}`)
94
+ const data = (await res.json()) as { features?: Array<{ attributes: unknown }>; error?: { message: string } }
95
+
96
+ if (data.error) throw new Error(`ArcGIS error on OID ${startOID}-${endOID}: ${data.error.message}`)
97
+
98
+ return (data.features ?? []).map((f) => f.attributes)
99
+ }
100
+
101
+ async function discoverTotalCount(): Promise<number> {
102
+ const url = new URL(`${FEATURE_SERVICE_URL}/query`)
103
+ url.searchParams.set("where", "1=1")
104
+ url.searchParams.set("returnCountOnly", "true")
105
+ url.searchParams.set("f", "json")
106
+ const res = await fetch(url, { signal: AbortSignal.timeout(30_000) })
107
+
108
+ if (!res.ok) throw new Error(`Failed to discover NAD record count: HTTP ${res.status}`)
109
+ const data = (await res.json()) as { count?: number }
110
+
111
+ if (typeof data.count !== "number") throw new Error("NAD count query returned no count field")
112
+
113
+ return data.count
114
+ }
115
+
116
+ /**
117
+ * Fetch a single chunk by paging through its OID range with bounded concurrency. Returns the count of records written
118
+ * and the count of pages that errored. The caller decides whether to mark the chunk complete based on errors === 0.
119
+ *
120
+ * NOTE(phase1): this is a JSON API pager, not a file download — the shared `downloadToFile` doesn't apply here.
121
+ */
122
+ async function fetchChunk(
123
+ chunkPath: string,
124
+ chunkStart: number,
125
+ chunkEnd: number,
126
+ pageSize: number,
127
+ concurrency: number,
128
+ report?: (line: string) => void
129
+ ): Promise<{ recordCount: number; errors: number }> {
130
+ const pageRanges: Array<[number, number]> = []
131
+
132
+ for (let cursor = chunkStart; cursor <= chunkEnd; cursor += pageSize) {
133
+ pageRanges.push([cursor, Math.min(cursor + pageSize - 1, chunkEnd)])
134
+ }
135
+
136
+ // Run bounded-concurrency page fetches. Results indexed by page slot for in-order write.
137
+ const pageResults: Array<{ rows: unknown[]; error: Error | null }> = pageRanges.map(() => ({
138
+ rows: [],
139
+ error: null,
140
+ }))
141
+ let nextSlot = 0
142
+ const workers = Array.from({ length: Math.min(concurrency, pageRanges.length) }, async () => {
143
+ while (true) {
144
+ const slot = nextSlot++
145
+
146
+ if (slot >= pageRanges.length) return
147
+ const [s, e] = pageRanges[slot]!
148
+
149
+ try {
150
+ pageResults[slot]!.rows = await fetchPage(s, e, pageSize)
151
+ } catch (err) {
152
+ pageResults[slot]!.error = err as Error
153
+ report?.(` ✗ page ${s}-${e}: ${(err as Error).message}`)
154
+ }
155
+ }
156
+ })
157
+ await Promise.all(workers)
158
+
159
+ // Single-writer phase — write all pages in OID order to keep NDJSON deterministic.
160
+ const lines: string[] = []
161
+ let errors = 0
162
+
163
+ for (const { rows, error } of pageResults) {
164
+ if (error) {
165
+ errors++
166
+ continue
167
+ }
168
+
169
+ for (const row of rows) {
170
+ lines.push(JSON.stringify(row))
171
+ }
172
+ }
173
+ await writeFile(chunkPath, lines.length === 0 ? "" : lines.join("\n") + "\n")
174
+
175
+ return { recordCount: lines.length, errors }
176
+ }
177
+
178
+ async function featureserverMode(options: FetchNADOptions, report?: (line: string) => void): Promise<FetchSummary> {
179
+ const chunkSize = options.chunkSize ?? 100_000
180
+ const pageSize = options.pageSize ?? 5_000
181
+ const concurrency = options.concurrency ?? 4
182
+ const startOID = options.startOID ?? 1
183
+
184
+ const chunkDir = join(options.outRoot, SLUG, "featureserver")
185
+ mkdirSync(chunkDir, { recursive: true })
186
+
187
+ report?.(`=== ${SLUG} / featureserver`)
188
+ report?.(` Discovering record count ...`)
189
+ const totalCount = await discoverTotalCount()
190
+ const endOID = options.endOID ?? totalCount
191
+ report?.(` Total records: ${totalCount.toLocaleString()}`)
192
+ report?.(` OID range: ${startOID.toLocaleString()} .. ${endOID.toLocaleString()}`)
193
+ report?.(` Chunk size: ${chunkSize}, page size: ${pageSize}, concurrency: ${concurrency}`)
194
+
195
+ let fetched = 0
196
+ let skipped = 0
197
+ let totalRecords = 0
198
+ let totalErrors = 0
199
+ const failedCodes: string[] = []
200
+
201
+ for (let cursor = startOID; cursor <= endOID; cursor += chunkSize) {
202
+ const chunkEnd = Math.min(cursor + chunkSize - 1, endOID)
203
+ const chunkName = `oids_${cursor}-${chunkEnd}`
204
+ const chunkPath = join(chunkDir, `${chunkName}.ndjson`)
205
+ const manifestPath = join(chunkDir, `${chunkName}.manifest.json`)
206
+
207
+ // Idempotency: skip a chunk only if it's marked complete (the bash version's bug was
208
+ // marking complete on partial-failure runs; we now only set complete after a clean fetch).
209
+ if (existsSync(chunkPath)) {
210
+ const recorded = await readManifest<ChunkManifest>(manifestPath)
211
+
212
+ if (recorded?.complete) {
213
+ skipped++
214
+ continue
215
+ }
216
+ }
217
+
218
+ report?.(` Fetching ${chunkName} ...`)
219
+ const t0 = Date.now()
220
+ const { recordCount, errors } = await fetchChunk(chunkPath, cursor, chunkEnd, pageSize, concurrency, report)
221
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1)
222
+ const bytes = statSync(chunkPath).size
223
+ const sha = await sha256File(chunkPath)
224
+
225
+ const manifest: ChunkManifest = {
226
+ source_url: FEATURE_SERVICE_URL,
227
+ oid_range: [cursor, chunkEnd],
228
+ downloaded_at: new Date().toISOString(),
229
+ filename: `${chunkName}.ndjson`,
230
+ sha256: sha,
231
+ bytes,
232
+ record_count: recordCount,
233
+ page_errors: errors,
234
+ complete: errors === 0,
235
+ }
236
+ await writeManifest(manifestPath, manifest)
237
+
238
+ const status = errors === 0 ? "✓" : `⚠ ${errors} page errors`
239
+ report?.(
240
+ ` ${status} ${recordCount.toLocaleString()} records in ${elapsed}s (${(bytes / 1024 / 1024).toFixed(1)} MB)`
241
+ )
242
+ fetched++
243
+ totalRecords += recordCount
244
+ totalErrors += errors
245
+
246
+ if (errors > 0) {
247
+ failedCodes.push(chunkName)
248
+ }
249
+ }
250
+
251
+ report?.(`=== featureserver summary ===`)
252
+ report?.(`chunks fetched: ${fetched} skipped: ${skipped}`)
253
+ report?.(`total records: ${totalRecords.toLocaleString()}`)
254
+ report?.(`page errors: ${totalErrors}`)
255
+ report?.(`output: ${chunkDir}`)
256
+
257
+ // `failed` counts page errors (the old exit-1 condition), `failedCodes` names the dirty chunks.
258
+ return { fetched, skipped, failed: totalErrors, failedCodes }
259
+ }
260
+
261
+ async function bulkMode(options: FetchNADOptions, report?: (line: string) => void): Promise<FetchSummary> {
262
+ if (!options.nadURL) {
263
+ throw new Error(
264
+ `bulk mode requires --nad-url. The DOT page is Akamai-gated; ` +
265
+ `visit https://www.transportation.gov/gis/national-address-database in a browser, ` +
266
+ `accept the disclaimer, and re-run with the pre-signed S3 URL.`
267
+ )
268
+ }
269
+ const destDir = join(options.outRoot, SLUG)
270
+ mkdirSync(destDir, { recursive: true })
271
+ const filename = new URL(options.nadURL).pathname.split("/").pop() ?? "NAD.zip"
272
+ const destPath = join(destDir, filename)
273
+
274
+ report?.(`=== ${SLUG} / ${filename}`)
275
+ report?.(` URL: ${options.nadURL.slice(0, 100)}${options.nadURL.length > 100 ? "…" : ""}`)
276
+
277
+ const { bytes } = await downloadToFile({ url: options.nadURL, dest: destPath, timeoutMs: 3 * 3600 * 1000, report })
278
+
279
+ const sha = await sha256File(destPath)
280
+ await writeManifest(join(destDir, "MANIFEST.json"), {
281
+ source_url: options.nadURL,
282
+ downloaded_at: new Date().toISOString(),
283
+ filename,
284
+ sha256: sha,
285
+ bytes,
286
+ })
287
+ report?.(` ✓ ${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB sha256=${sha}`)
288
+
289
+ return { fetched: 1, skipped: 0, failed: 0, failedCodes: [] }
290
+ }
291
+
292
+ export async function fetchNAD(options: FetchNADOptions, report?: (line: string) => void): Promise<FetchSummary> {
293
+ const mode = options.mode ?? "featureserver"
294
+
295
+ if (mode === "featureserver") {
296
+ return featureserverMode(options, report)
297
+ }
298
+
299
+ if (mode === "bulk") {
300
+ return bulkMode(options, report)
301
+ }
302
+
303
+ throw new Error(`unknown mode "${String(mode)}" (expected featureserver|bulk)`)
304
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Re-fetch the NPPES (National Plan and Provider Enumeration System) full monthly data
7
+ * dissemination file. ~7M provider rows with venue+address data. Source for the `usgov-nppes`
8
+ * adapter. US Public Domain.
9
+ *
10
+ * The file is published monthly by CMS. This module discovers the current filename by scraping the
11
+ * NPI_Files.html index, then downloads the ZIP and extracts only the main registry CSV
12
+ * (npidata_pfile_*.csv). The smaller endpoint/othername/pl files stay zipped — we don't need them.
13
+ *
14
+ * Uses Node's built-in fetch (gzip/brotli) to parse the HTML index and download the ZIP, and
15
+ * streaming sha256 instead of sha256sum. The ZIP is unpacked with the `unzip` binary via
16
+ * `node:child_process` (no clean Node equivalent for member listing + selective extraction). NOTE:
17
+ * the old bash fetcher used `curl --continue-at -` to resume a partial download; native fetch has
18
+ * no resume, so a partial run re-downloads from the start.
19
+ *
20
+ * Invoke via `mailwoman corpus fetch nppes --out-root <path>`. Idempotent: if dest CSV exists and
21
+ * sha256 matches MANIFEST, skips download.
22
+ */
23
+
24
+ import { execFile } from "node:child_process"
25
+ import { existsSync, mkdirSync, statSync } from "node:fs"
26
+ import { rm } from "node:fs/promises"
27
+ import { join } from "node:path"
28
+ import { promisify } from "node:util"
29
+
30
+ import { sha256File } from "@mailwoman/core/utils"
31
+
32
+ import type { BaseFetchOptions, FetchSummary } from "./download.ts"
33
+ import { downloadToFile, readManifest, writeManifest } from "./download.ts"
34
+
35
+ const execFileAsync = promisify(execFile)
36
+
37
+ const INDEX_URL = "https://download.cms.gov/nppes/NPI_Files.html"
38
+ const BASE_URL = "https://download.cms.gov/nppes"
39
+ const SLUG = "usgov-nppes"
40
+
41
+ export type FetchNPPESOptions = BaseFetchOptions
42
+
43
+ interface SourceManifest {
44
+ source_url: string
45
+ downloaded_at: string
46
+ filename: string
47
+ sha256: string
48
+ bytes: number
49
+ }
50
+
51
+ /**
52
+ * Scrape the NPI_Files.html index for the latest full monthly ZIP. Full-replacement files match
53
+ * `NPPES_Data_Dissemination_<Month>_<Year>*.zip`; weekly files carry a `MMDDYY_MMDDYY` date range, which we exclude.
54
+ */
55
+ async function discoverLatestZip(): Promise<string | undefined> {
56
+ const res = await fetch(INDEX_URL, {
57
+ headers: { "Accept-Encoding": "gzip, br" },
58
+ signal: AbortSignal.timeout(60_000),
59
+ })
60
+
61
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} on ${INDEX_URL}`)
62
+ const html = await res.text()
63
+
64
+ for (const match of html.matchAll(/NPPES_Data_Dissemination_[A-Za-z]+_\d{4}[^"]*\.zip/g)) {
65
+ const name = match[0]
66
+
67
+ if (name && !/\d{6}_\d{6}/.test(name)) return name
68
+ }
69
+
70
+ return undefined
71
+ }
72
+
73
+ /** Extract the main registry CSV name (npidata_pfile_*.csv) from a ZIP's `unzip -l` listing. */
74
+ async function findNpidataCSV(zipPath: string): Promise<string | undefined> {
75
+ const listing = await execFileAsync("unzip", ["-l", zipPath])
76
+
77
+ for (const line of listing.stdout.split("\n")) {
78
+ const match = /npidata_pfile\S+\.csv/i.exec(line)
79
+
80
+ if (match?.[0]) return match[0]
81
+ }
82
+
83
+ return undefined
84
+ }
85
+
86
+ export async function fetchNPPES(options: FetchNPPESOptions, report?: (line: string) => void): Promise<FetchSummary> {
87
+ const destDir = join(options.outRoot, SLUG)
88
+ mkdirSync(destDir, { recursive: true })
89
+ const manifestPath = join(destDir, "MANIFEST.json")
90
+
91
+ report?.(`=== ${SLUG}`)
92
+ report?.(` Discovering latest full-replacement ZIP from ${INDEX_URL} ...`)
93
+
94
+ const zipFilename = await discoverLatestZip()
95
+
96
+ if (!zipFilename) {
97
+ report?.(` ✗ Could not discover ZIP filename from ${INDEX_URL}`)
98
+
99
+ return { fetched: 0, skipped: 0, failed: 1, failedCodes: [SLUG] }
100
+ }
101
+
102
+ const zipURL = `${BASE_URL}/${zipFilename}`
103
+ const zipDest = join(destDir, zipFilename)
104
+ report?.(` Latest full file: ${zipFilename}`)
105
+
106
+ // ------------------------------------------------------------------
107
+ // Idempotency check: if the main CSV already exists and sha matches,
108
+ // skip re-download.
109
+ // ------------------------------------------------------------------
110
+ const recorded = await readManifest<Partial<SourceManifest>>(manifestPath)
111
+
112
+ if (recorded?.sha256 && recorded.filename) {
113
+ const recordedPath = join(destDir, recorded.filename)
114
+
115
+ if (existsSync(recordedPath) && (await sha256File(recordedPath)) === recorded.sha256) {
116
+ report?.(" ✓ Already current (sha256 matches MANIFEST) — skipping download.")
117
+
118
+ return { fetched: 0, skipped: 1, failed: 0, failedCodes: [] }
119
+ }
120
+ }
121
+
122
+ // ------------------------------------------------------------------
123
+ // Download ZIP (large; 60-minute timeout)
124
+ // ------------------------------------------------------------------
125
+ report?.(` Downloading ${zipURL} ...`)
126
+ const { bytes: zipSize } = await downloadToFile({
127
+ url: zipURL,
128
+ dest: zipDest,
129
+ timeoutMs: 3_600_000,
130
+ headers: { "Accept-Encoding": "gzip, br" },
131
+ report,
132
+ })
133
+ report?.(` Downloaded: ${(zipSize / 1024 / 1024).toFixed(1)} MB`)
134
+
135
+ // ------------------------------------------------------------------
136
+ // Extract only the main registry CSV (npidata_pfile_*.csv)
137
+ // ------------------------------------------------------------------
138
+ report?.(" Extracting npidata_pfile CSV from ZIP ...")
139
+ const csvName = await findNpidataCSV(zipDest)
140
+
141
+ if (!csvName) {
142
+ report?.(" ✗ Could not find npidata_pfile CSV inside ZIP")
143
+
144
+ return { fetched: 0, skipped: 0, failed: 1, failedCodes: [SLUG] }
145
+ }
146
+
147
+ report?.(` Extracting: ${csvName}`)
148
+ await execFileAsync("unzip", ["-o", "-j", zipDest, csvName, "-d", destDir])
149
+
150
+ const csvDest = join(destDir, csvName)
151
+ const csvSize = statSync(csvDest).size
152
+ const csvSha = await sha256File(csvDest)
153
+ report?.(` CSV size: ${(csvSize / 1024 / 1024).toFixed(1)} MB`)
154
+
155
+ // ------------------------------------------------------------------
156
+ // Remove the ZIP to reclaim ~1 GB (the CSV is what adapters consume)
157
+ // ------------------------------------------------------------------
158
+ await rm(zipDest, { force: true })
159
+ report?.(" Removed ZIP (CSV kept)")
160
+
161
+ // ------------------------------------------------------------------
162
+ // Write MANIFEST (records the extracted CSV, not the ZIP)
163
+ // ------------------------------------------------------------------
164
+ const manifest: SourceManifest = {
165
+ source_url: zipURL,
166
+ downloaded_at: new Date().toISOString(),
167
+ filename: csvName,
168
+ sha256: csvSha,
169
+ bytes: csvSize,
170
+ }
171
+ await writeManifest(manifestPath, manifest)
172
+
173
+ report?.(` ✓ ${(csvSize / 1024 / 1024).toFixed(1)} MB sha256=${csvSha}`)
174
+ report?.(` MANIFEST written to ${manifestPath}`)
175
+
176
+ return { fetched: 1, skipped: 0, failed: 0, failedCodes: [] }
177
+ }