@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.
- 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,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
|
+
}
|