@mailwoman/corpus 7.2.0 → 7.3.0

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,288 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Fetch the full French BAN (Base Adresse Nationale) — all metropolitan départements (01-95, 2A,
7
+ * 2B) plus 5 overseas DOM/TOM (971-976 excl. 975).
8
+ *
9
+ * Source: https://adresse.data.gouv.fr/data/ban/adresses/latest/csv/
10
+ * Licence: Licence Ouverte 2.0 (attribution required — Tier B).
11
+ *
12
+ * Files already present with matching sha256 are skipped (re-runnable). Downloads `.csv.gz`,
13
+ * decompresses to `.csv`, deletes the `.gz` artifact. One shared `MANIFEST.json` at
14
+ * `<outRoot>/ban/MANIFEST.json` covers all codes.
15
+ *
16
+ * Invoke via `mailwoman corpus fetch ban --out-root <path>`. Built-in `fetch` with gzip/brotli
17
+ * decompression replaces curl; native `node:zlib` gunzip replaces the `gunzip` subprocess; no
18
+ * Python.
19
+ */
20
+
21
+ import { existsSync, mkdirSync, statSync } from "node:fs"
22
+ import { readFile, unlink, writeFile } from "node:fs/promises"
23
+ import { join } from "node:path"
24
+ import { setTimeout as sleep } from "node:timers/promises"
25
+ import { gunzipSync } from "node:zlib"
26
+
27
+ import { sha256File } from "@mailwoman/core/utils"
28
+
29
+ import type { BaseFetchOptions, FetchSummary } from "./download.ts"
30
+ import { downloadToFile, loadManifestEntries, writeManifest } from "./download.ts"
31
+
32
+ const BASE_URL = "https://adresse.data.gouv.fr/data/ban/adresses/latest/csv"
33
+
34
+ /**
35
+ * All département codes — metropolitan 01-95 (with 2A/2B for Corsica instead of 20) plus overseas DOM/TOM. Codes do not
36
+ * change.
37
+ */
38
+ const DEPT_CODES = [
39
+ "01",
40
+ "02",
41
+ "03",
42
+ "04",
43
+ "05",
44
+ "06",
45
+ "07",
46
+ "08",
47
+ "09",
48
+ "10",
49
+ "11",
50
+ "12",
51
+ "13",
52
+ "14",
53
+ "15",
54
+ "16",
55
+ "17",
56
+ "18",
57
+ "19",
58
+ "21",
59
+ "22",
60
+ "23",
61
+ "24",
62
+ "25",
63
+ "26",
64
+ "27",
65
+ "28",
66
+ "29",
67
+ "2A",
68
+ "2B",
69
+ "30",
70
+ "31",
71
+ "32",
72
+ "33",
73
+ "34",
74
+ "35",
75
+ "36",
76
+ "37",
77
+ "38",
78
+ "39",
79
+ "40",
80
+ "41",
81
+ "42",
82
+ "43",
83
+ "44",
84
+ "45",
85
+ "46",
86
+ "47",
87
+ "48",
88
+ "49",
89
+ "50",
90
+ "51",
91
+ "52",
92
+ "53",
93
+ "54",
94
+ "55",
95
+ "56",
96
+ "57",
97
+ "58",
98
+ "59",
99
+ "60",
100
+ "61",
101
+ "62",
102
+ "63",
103
+ "64",
104
+ "65",
105
+ "66",
106
+ "67",
107
+ "68",
108
+ "69",
109
+ "70",
110
+ "71",
111
+ "72",
112
+ "73",
113
+ "74",
114
+ "75",
115
+ "76",
116
+ "77",
117
+ "78",
118
+ "79",
119
+ "80",
120
+ "81",
121
+ "82",
122
+ "83",
123
+ "84",
124
+ "85",
125
+ "86",
126
+ "87",
127
+ "88",
128
+ "89",
129
+ "90",
130
+ "91",
131
+ "92",
132
+ "93",
133
+ "94",
134
+ "95",
135
+ "971",
136
+ "972",
137
+ "973",
138
+ "974",
139
+ "976",
140
+ ]
141
+
142
+ export interface BanManifestEntry {
143
+ dept_code: string
144
+ filename: string
145
+ source_url: string
146
+ downloaded_at: string
147
+ sha256: string
148
+ bytes: number
149
+ }
150
+
151
+ export type FetchBanOptions = BaseFetchOptions
152
+
153
+ /** Mimic `numfmt --to=iec` for a friendly byte-size log line. */
154
+ function iec(bytes: number): string {
155
+ if (bytes < 1024) return String(bytes)
156
+ const units = ["K", "M", "G", "T", "P"]
157
+ let value = bytes / 1024
158
+ let i = 0
159
+
160
+ while (value >= 1024 && i < units.length - 1) {
161
+ value /= 1024
162
+ i++
163
+ }
164
+
165
+ const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString()
166
+
167
+ return `${rounded}${units[i] ?? ""}`
168
+ }
169
+
170
+ export async function fetchBan(options: FetchBanOptions, report?: (line: string) => void): Promise<FetchSummary> {
171
+ const banDir = join(options.outRoot, "ban")
172
+ const manifestPath = join(banDir, "MANIFEST.json")
173
+ mkdirSync(banDir, { recursive: true })
174
+
175
+ // Load existing entries (code -> entry): skip detection + preservation of untouched codes.
176
+ const entries = await loadManifestEntries<BanManifestEntry>(manifestPath, (entry) => entry.dept_code)
177
+
178
+ let fetched = 0
179
+ let skipped = 0
180
+ let failed = 0
181
+ const failedCodes: string[] = []
182
+
183
+ for (const code of DEPT_CODES) {
184
+ const filename = `adresses-${code}.csv`
185
+ const gzFile = join(banDir, `${filename}.gz`)
186
+ const csvFile = join(banDir, filename)
187
+ const url = `${BASE_URL}/adresses-${code}.csv.gz`
188
+
189
+ report?.(`=== dept ${code}`)
190
+
191
+ // If the CSV already exists, compare its sha256 against the manifest.
192
+ if (existsSync(csvFile)) {
193
+ const existingSha = await sha256File(csvFile)
194
+ const recordedSha = entries.get(code)?.sha256
195
+
196
+ if (recordedSha && existingSha === recordedSha) {
197
+ report?.(` → already present + sha matches — skipping`)
198
+ skipped++
199
+ continue
200
+ }
201
+
202
+ report?.(` → present but sha mismatch or no manifest entry — re-fetching`)
203
+ await unlink(csvFile)
204
+ }
205
+
206
+ // Download the gzipped CSV.
207
+ try {
208
+ await downloadToFile({
209
+ url,
210
+ dest: gzFile,
211
+ timeoutMs: 600_000,
212
+ headers: { "Accept-Encoding": "gzip, br" },
213
+ report,
214
+ })
215
+ } catch (err) {
216
+ report?.(` ✗ download failed: ${url} (${(err as Error).message})`)
217
+ failed++
218
+ failedCodes.push(code)
219
+ continue
220
+ }
221
+
222
+ // Guard against truncated 404/error pages.
223
+ const gzSize = statSync(gzFile).size
224
+
225
+ if (gzSize < 1024) {
226
+ report?.(` ✗ response too small (${gzSize} bytes) — probable 404 / error page`)
227
+ await unlink(gzFile)
228
+ failed++
229
+ failedCodes.push(code)
230
+ continue
231
+ }
232
+
233
+ // Decompress in-place; delete the .gz.
234
+ try {
235
+ await writeFile(csvFile, gunzipSync(await readFile(gzFile)))
236
+ } catch (err) {
237
+ report?.(` ✗ decompress failed: ${(err as Error).message}`)
238
+ await unlink(gzFile)
239
+ failed++
240
+ failedCodes.push(code)
241
+ continue
242
+ }
243
+ await unlink(gzFile)
244
+
245
+ if (!existsSync(csvFile)) {
246
+ report?.(` ✗ decompressed file not found at ${csvFile}`)
247
+ failed++
248
+ failedCodes.push(code)
249
+ continue
250
+ }
251
+
252
+ const bytes = statSync(csvFile).size
253
+ const sha = await sha256File(csvFile)
254
+
255
+ entries.set(code, {
256
+ dept_code: code,
257
+ filename,
258
+ source_url: url,
259
+ downloaded_at: new Date().toISOString(),
260
+ sha256: sha,
261
+ bytes,
262
+ })
263
+
264
+ report?.(` ✓ ${iec(bytes)} sha256=${sha}`)
265
+ fetched++
266
+
267
+ // Be a polite citizen — short pause between requests.
268
+ await sleep(200)
269
+ }
270
+
271
+ // Write the consolidated MANIFEST.json (entries sorted by dept_code, codepoint order).
272
+ const sorted = [...entries.values()].sort((a, b) =>
273
+ a.dept_code < b.dept_code ? -1 : a.dept_code > b.dept_code ? 1 : 0
274
+ )
275
+ await writeManifest(manifestPath, sorted)
276
+ report?.(`Wrote ${manifestPath} with ${sorted.length} entries.`)
277
+
278
+ report?.(`=== summary ===`)
279
+ report?.(`fetched: ${fetched}`)
280
+ report?.(`skipped: ${skipped} (already present + sha matched)`)
281
+ report?.(`failed: ${failed}`)
282
+
283
+ if (failedCodes.length > 0) {
284
+ report?.(`failed codes: ${failedCodes.join(" ")}`)
285
+ }
286
+
287
+ return { fetched, skipped, failed, failedCodes }
288
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Download + manifest plumbing for the `mailwoman corpus fetch <source>` family — one download-with-retry and
7
+ * one MANIFEST.json idiom instead of the per-script clones the 2026-07-09 dedupe survey counted
8
+ * (6× `downloadToFile`, 2× `isTransientStatus`, 9× manifest writes).
9
+ */
10
+
11
+ import { existsSync } from "node:fs"
12
+ import { readFile, writeFile } from "node:fs/promises"
13
+ import { setTimeout as sleep } from "node:timers/promises"
14
+
15
+ /** The option base every `mailwoman corpus fetch <source>` module extends. */
16
+ export interface BaseFetchOptions {
17
+ /** Destination root for downloaded source data. Each source writes its own subdirectory. */
18
+ outRoot: string
19
+ }
20
+
21
+ /** The per-run result every fetch module returns; the command maps `failed > 0` to exit code 1. */
22
+ export interface FetchSummary {
23
+ fetched: number
24
+ skipped: number
25
+ failed: number
26
+ failedCodes: string[]
27
+ }
28
+
29
+ /** A status worth retrying: rate limiting or a server-side failure. */
30
+ export function isTransientStatus(status: number): boolean {
31
+ return status === 429 || (status >= 500 && status <= 599)
32
+ }
33
+
34
+ export interface DownloadOptions {
35
+ url: string
36
+ dest: string
37
+ /** Per-attempt timeout. Default 10 minutes — these are multi-GB government dumps. */
38
+ timeoutMs?: number
39
+ /** Extra attempts after the first, taken only on transient statuses or network errors. Default 0. */
40
+ retries?: number
41
+ /** Delay between attempts. Default 5s. */
42
+ retryDelayMs?: number
43
+ headers?: Record<string, string>
44
+ report?: (line: string) => void
45
+ }
46
+
47
+ /**
48
+ * Download `url` to `dest` with per-attempt timeout and transient-status retry. Throws on a non-transient HTTP status
49
+ * or once retries are exhausted. Returns the byte count written.
50
+ */
51
+ export async function downloadToFile(options: DownloadOptions): Promise<{ bytes: number }> {
52
+ const { url, dest, timeoutMs = 600_000, retries = 0, retryDelayMs = 5_000, headers, report } = options
53
+ let lastError: unknown
54
+
55
+ for (let attempt = 0; attempt <= retries; attempt++) {
56
+ if (attempt > 0) {
57
+ report?.(`retry ${attempt}/${retries} after ${retryDelayMs}ms — ${url}`)
58
+ await sleep(retryDelayMs)
59
+ }
60
+
61
+ let res: Response
62
+
63
+ try {
64
+ res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) })
65
+ } catch (error) {
66
+ // AbortSignal timeouts and network-level failures are retryable.
67
+ lastError = error
68
+ continue
69
+ }
70
+
71
+ if (!res.ok) {
72
+ const error = new Error(`HTTP ${res.status} ${res.statusText} — ${url}`)
73
+
74
+ if (!isTransientStatus(res.status)) throw error
75
+ lastError = error
76
+ continue
77
+ }
78
+
79
+ try {
80
+ const buffer = Buffer.from(await res.arrayBuffer())
81
+ await writeFile(dest, buffer)
82
+
83
+ return { bytes: buffer.byteLength }
84
+ } catch (error) {
85
+ // A mid-stream abort while reading the body is retryable too.
86
+ lastError = error
87
+ }
88
+ }
89
+
90
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
91
+ }
92
+
93
+ /** Read a MANIFEST.json; `null` when missing or corrupt (callers re-fetch from scratch). */
94
+ export async function readManifest<T>(path: string): Promise<T | null> {
95
+ if (!existsSync(path)) return null
96
+
97
+ try {
98
+ return JSON.parse(await readFile(path, "utf8")) as T
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ /** Load manifest entries into a map so untouched keys survive a partial re-fetch. */
105
+ export async function loadManifestEntries<T>(path: string, key: (entry: T) => string): Promise<Map<string, T>> {
106
+ const entries = new Map<string, T>()
107
+ const parsed = await readManifest<T[]>(path)
108
+
109
+ for (const entry of parsed ?? []) {
110
+ entries.set(key(entry), entry)
111
+ }
112
+
113
+ return entries
114
+ }
115
+
116
+ /** Write a MANIFEST.json in the house shape: pretty-printed, trailing newline. */
117
+ export async function writeManifest(path: string, manifest: unknown): Promise<void> {
118
+ await writeFile(path, JSON.stringify(manifest, null, 2) + "\n")
119
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Re-fetch the HRSA Health Center Service Delivery Sites CSV. Source for the `usgov-hrsa-fqhc`
7
+ * adapter. US Public Domain.
8
+ *
9
+ * Uses Node's built-in fetch (gzip/brotli) and streaming sha256 instead of curl + sha256sum, and
10
+ * writes the same sibling `MANIFEST.json` (origin URL + fetch timestamp + byte count + sha256) so
11
+ * downstream adapters can verify provenance.
12
+ *
13
+ * Invoke via `mailwoman corpus fetch hrsa --out-root <path>`.
14
+ */
15
+
16
+ import { mkdirSync } from "node:fs"
17
+ import { join } from "node:path"
18
+
19
+ import { sha256File } from "@mailwoman/core/utils"
20
+
21
+ import type { BaseFetchOptions, FetchSummary } from "./download.ts"
22
+ import { downloadToFile, writeManifest } from "./download.ts"
23
+
24
+ const SLUG = "usgov-hrsa-fqhc"
25
+ const FILENAME = "Health_Center_Service_Delivery_and_LookAlike_Sites.csv"
26
+ const SOURCE_URL = `https://data.hrsa.gov/DataDownload/DD_Files/${FILENAME}`
27
+
28
+ export type FetchHRSAOptions = BaseFetchOptions
29
+
30
+ interface SourceManifest {
31
+ source_url: string
32
+ downloaded_at: string
33
+ filename: string
34
+ sha256: string
35
+ bytes: number
36
+ }
37
+
38
+ export async function fetchHRSA(options: FetchHRSAOptions, report?: (line: string) => void): Promise<FetchSummary> {
39
+ const destDir = join(options.outRoot, SLUG)
40
+ mkdirSync(destDir, { recursive: true })
41
+ const dest = join(destDir, FILENAME)
42
+
43
+ report?.(`=== ${SLUG} / ${FILENAME}`)
44
+ const { bytes } = await downloadToFile({
45
+ url: SOURCE_URL,
46
+ dest,
47
+ timeoutMs: 600_000,
48
+ headers: { "Accept-Encoding": "gzip, br" },
49
+ report,
50
+ })
51
+ const sha = await sha256File(dest)
52
+
53
+ const manifest: SourceManifest = {
54
+ source_url: SOURCE_URL,
55
+ downloaded_at: new Date().toISOString(),
56
+ filename: FILENAME,
57
+ sha256: sha,
58
+ bytes,
59
+ }
60
+ await writeManifest(join(destDir, "MANIFEST.json"), manifest)
61
+
62
+ report?.(` ✓ ${(bytes / 1024 / 1024).toFixed(1)} MB sha256=${sha}`)
63
+
64
+ return { fetched: 1, skipped: 0, failed: 0, failedCodes: [] }
65
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Re-fetch the IMLS Public Libraries Survey (PLS) outlet-level data. Each US public library branch
7
+ * (outlet) is one row, ~17K rows with address fields. Source for the `usgov-imls-pls` adapter. US
8
+ * Public Domain (federal statistical survey).
9
+ *
10
+ * The FY 2023 release is the most current as of 2026-05. IMLS ships a single ZIP containing CSV,
11
+ * SAS, and SPSS variants. We extract the outlet-level CSV (pls_fy*_outlet*.csv or similar) and
12
+ * discard the rest. The administrative-entity (system-level) CSV is intentionally skipped — it has
13
+ * no per-branch address detail.
14
+ *
15
+ * Uses Node's built-in fetch (gzip/brotli) and streaming sha256 instead of curl + sha256sum. The
16
+ * ZIP is unpacked with the `unzip` binary via `node:child_process` (no clean Node equivalent for
17
+ * member listing + selective extraction).
18
+ *
19
+ * Invoke via `mailwoman corpus fetch imls-pls --out-root <path>`. Idempotent: if dest CSV exists
20
+ * and sha matches MANIFEST, skips download.
21
+ */
22
+
23
+ import { execFile } from "node:child_process"
24
+ import { existsSync, mkdirSync, statSync } from "node:fs"
25
+ import { rm } from "node:fs/promises"
26
+ import { basename, join } from "node:path"
27
+ import { promisify } from "node:util"
28
+
29
+ import { sha256File } from "@mailwoman/core/utils"
30
+
31
+ import type { BaseFetchOptions, FetchSummary } from "./download.ts"
32
+ import { downloadToFile, readManifest, writeManifest } from "./download.ts"
33
+
34
+ const execFileAsync = promisify(execFile)
35
+
36
+ // The PLS FY 2023 bulk CSV ZIP (most recent as of 2026-05).
37
+ // If IMLS publishes a newer year, update this URL.
38
+ const ZIP_URL = "https://www.imls.gov/sites/default/files/2025-08/pls_fy2023_csv.zip"
39
+ const SLUG = "usgov-imls-pls"
40
+
41
+ export type FetchIMLSPLSOptions = 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
+ /** Return the filenames listed inside a ZIP (the trailing column of each `unzip -l` row). */
52
+ async function listZipEntries(zipPath: string): Promise<string[]> {
53
+ const listing = await execFileAsync("unzip", ["-l", zipPath])
54
+
55
+ return listing.stdout
56
+ .split("\n")
57
+ .map((line) => line.trim().split(/\s+/).pop() ?? "")
58
+ .filter((name) => name.length > 0)
59
+ }
60
+
61
+ export async function fetchIMLSPLS(
62
+ options: FetchIMLSPLSOptions,
63
+ report?: (line: string) => void
64
+ ): Promise<FetchSummary> {
65
+ const destDir = join(options.outRoot, SLUG)
66
+ mkdirSync(destDir, { recursive: true })
67
+
68
+ const zipDest = join(destDir, basename(ZIP_URL))
69
+ const manifestPath = join(destDir, "MANIFEST.json")
70
+
71
+ report?.(`=== ${SLUG}`)
72
+
73
+ // ------------------------------------------------------------------
74
+ // Idempotency check: if outlet CSV already exists and sha matches, skip.
75
+ // ------------------------------------------------------------------
76
+ const recorded = await readManifest<Partial<SourceManifest>>(manifestPath)
77
+
78
+ if (recorded?.sha256 && recorded.filename) {
79
+ const recordedPath = join(destDir, recorded.filename)
80
+
81
+ if (existsSync(recordedPath) && (await sha256File(recordedPath)) === recorded.sha256) {
82
+ report?.(" ✓ Already current (sha256 matches MANIFEST) — skipping download.")
83
+
84
+ return { fetched: 0, skipped: 1, failed: 0, failedCodes: [] }
85
+ }
86
+ }
87
+
88
+ // ------------------------------------------------------------------
89
+ // Download ZIP
90
+ // ------------------------------------------------------------------
91
+ report?.(` Downloading ${ZIP_URL} ...`)
92
+ const { bytes: zipSize } = await downloadToFile({
93
+ url: ZIP_URL,
94
+ dest: zipDest,
95
+ timeoutMs: 600_000,
96
+ headers: { "Accept-Encoding": "gzip, br" },
97
+ report,
98
+ })
99
+ report?.(` Downloaded: ${(zipSize / 1024 / 1024).toFixed(1)} MB`)
100
+
101
+ if (zipSize < 1024) {
102
+ report?.(` ✗ Response too small (${zipSize} bytes) — probable error page`)
103
+
104
+ return { fetched: 0, skipped: 0, failed: 1, failedCodes: [SLUG] }
105
+ }
106
+
107
+ // ------------------------------------------------------------------
108
+ // Discover the outlet-level CSV inside the ZIP.
109
+ // Outlet files match: pls_fy*outlet*.csv (case-insensitive)
110
+ // Administrative-entity files match: pls_fy*ae*.csv — we skip those.
111
+ // ------------------------------------------------------------------
112
+ report?.(" Inspecting ZIP contents ...")
113
+ const entries = await listZipEntries(zipDest)
114
+
115
+ let csvName = entries.find((name) => /pls_fy.*outlet.*\.csv/i.test(name))
116
+
117
+ // Fallback: if IMLS renames the file, grab any CSV that is NOT the ae file.
118
+ if (!csvName) {
119
+ csvName = entries.find((name) => /\.csv$/i.test(name) && !/system|state|_ae\b|_se\b/i.test(name))
120
+ }
121
+
122
+ if (!csvName) {
123
+ report?.(" Available files in ZIP:")
124
+
125
+ for (const name of entries) {
126
+ report?.(` ${name}`)
127
+ }
128
+ report?.(" ✗ Could not identify outlet CSV — inspect above listing and update this module")
129
+
130
+ return { fetched: 0, skipped: 0, failed: 1, failedCodes: [SLUG] }
131
+ }
132
+
133
+ report?.(` Extracting outlet CSV: ${csvName}`)
134
+ await execFileAsync("unzip", ["-o", "-j", zipDest, csvName, "-d", destDir])
135
+
136
+ const csvDest = join(destDir, basename(csvName))
137
+ const csvSize = statSync(csvDest).size
138
+ const csvSha = await sha256File(csvDest)
139
+
140
+ // ------------------------------------------------------------------
141
+ // Remove ZIP (small, but keep destDir clean)
142
+ // ------------------------------------------------------------------
143
+ await rm(zipDest, { force: true })
144
+ report?.(" Removed ZIP (CSV kept)")
145
+
146
+ // ------------------------------------------------------------------
147
+ // Write MANIFEST
148
+ // ------------------------------------------------------------------
149
+ const manifest: SourceManifest = {
150
+ source_url: ZIP_URL,
151
+ downloaded_at: new Date().toISOString(),
152
+ filename: basename(csvName),
153
+ sha256: csvSha,
154
+ bytes: csvSize,
155
+ }
156
+ await writeManifest(manifestPath, manifest)
157
+
158
+ report?.(` ✓ ${(csvSize / 1024 / 1024).toFixed(1)} MB sha256=${csvSha}`)
159
+ report?.(` MANIFEST written to ${manifestPath}`)
160
+
161
+ return { fetched: 1, skipped: 0, failed: 0, failedCodes: [] }
162
+ }