@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,142 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Re-fetch the state-level open-data sources (NY/TX/DE/OR notaries, IA contractors, WA health
7
+ * providers, HI lobbyists). Reproducible recovery if `$MAILWOMAN_DATA_ROOT` is lost.
8
+ *
9
+ * HI public schools is fetched separately by `mailwoman corpus fetch state-hi-schools` — its
10
+ * upstream is an XLSX workbook that requires an openpyxl-driven sheet-concatenation pre-step
11
+ * before the adapter can consume it.
12
+ *
13
+ * Each source lands in its own subdirectory of `<outRoot>/<slug>/` along with a `MANIFEST.json`
14
+ * recording origin URL + download timestamp + sha256 so downstream adapters can verify provenance.
15
+ *
16
+ * Invoke via `mailwoman corpus fetch state-sources --out-root <path>`. Uses Node's built-in fetch
17
+ * (gzip/brotli) and streaming sha256 instead of curl + sha256sum.
18
+ */
19
+
20
+ import { mkdirSync } from "node:fs"
21
+ import { join } from "node:path"
22
+
23
+ import { sha256File } from "@mailwoman/core/utils"
24
+
25
+ import type { BaseFetchOptions, FetchSummary } from "./download.ts"
26
+ import { downloadToFile, writeManifest } from "./download.ts"
27
+
28
+ export type FetchStateSourcesOptions = BaseFetchOptions
29
+
30
+ interface Source {
31
+ slug: string
32
+ filename: string
33
+ url: string
34
+ }
35
+
36
+ const SOURCES: readonly Source[] = [
37
+ {
38
+ slug: "state-ny-notaries",
39
+ filename: "NY_Commissioned_Notaries.csv",
40
+ url: "https://data.ny.gov/api/views/rwbv-mz6z/rows.csv?accessType=DOWNLOAD",
41
+ },
42
+ {
43
+ slug: "state-tx-notaries",
44
+ filename: "TX_Notary_Public_Commissions.csv",
45
+ url: "https://data.texas.gov/api/views/gmd3-bnrd/rows.csv?accessType=DOWNLOAD",
46
+ },
47
+ {
48
+ slug: "state-de-notaries",
49
+ filename: "DE_Notaries_Commissioned.csv",
50
+ url: "https://data.delaware.gov/api/views/q8dr-mj6p/rows.csv?accessType=DOWNLOAD",
51
+ },
52
+ {
53
+ slug: "state-or-notaries",
54
+ filename: "OR_Active_Notaries.csv",
55
+ url: "https://data.oregon.gov/api/views/j2pk-zk6z/rows.csv?accessType=DOWNLOAD",
56
+ },
57
+ {
58
+ slug: "state-ia-contractors",
59
+ filename: "IA_Active_Construction_Contractor_Registrations.csv",
60
+ url: "https://data.iowa.gov/api/views/dpf3-iz94/rows.csv?accessType=DOWNLOAD",
61
+ },
62
+ {
63
+ slug: "state-wa-health-providers",
64
+ filename: "WA_Health_Care_Provider_Credential_Data.csv",
65
+ url: "https://data.wa.gov/api/views/qxh8-f4bd/rows.csv?accessType=DOWNLOAD",
66
+ },
67
+ {
68
+ slug: "state-hi-lobbyists",
69
+ filename: "HI_Lobbyist_Registration_Statements.csv",
70
+ url: "https://data.hawaii.gov/api/views/cm7c-skav/rows.csv?accessType=DOWNLOAD",
71
+ },
72
+ ]
73
+
74
+ interface SourceManifest {
75
+ source_url: string
76
+ downloaded_at: string
77
+ filename: string
78
+ sha256: string
79
+ bytes: number
80
+ }
81
+
82
+ export async function fetchStateSources(
83
+ options: FetchStateSourcesOptions,
84
+ report?: (line: string) => void
85
+ ): Promise<FetchSummary> {
86
+ mkdirSync(options.outRoot, { recursive: true })
87
+
88
+ let fetched = 0
89
+ let failed = 0
90
+ const failedCodes: string[] = []
91
+
92
+ for (const { slug, filename, url } of SOURCES) {
93
+ const destDir = join(options.outRoot, slug)
94
+ mkdirSync(destDir, { recursive: true })
95
+ const dest = join(destDir, filename)
96
+
97
+ report?.(`=== ${slug} / ${filename}`)
98
+
99
+ let bytes: number
100
+
101
+ try {
102
+ ;({ bytes } = await downloadToFile({
103
+ url,
104
+ dest,
105
+ timeoutMs: 600_000,
106
+ headers: { "Accept-Encoding": "gzip, br" },
107
+ report,
108
+ }))
109
+ } catch (err) {
110
+ report?.(` ✗ download failed for ${url}: ${(err as Error).message}`)
111
+ failed++
112
+ failedCodes.push(slug)
113
+ continue
114
+ }
115
+
116
+ if (bytes < 1024) {
117
+ report?.(` ✗ response too small (${bytes} bytes) — probable 404 / error page`)
118
+ failed++
119
+ failedCodes.push(slug)
120
+ continue
121
+ }
122
+
123
+ const sha = await sha256File(dest)
124
+ const manifest: SourceManifest = {
125
+ source_url: url,
126
+ downloaded_at: new Date().toISOString(),
127
+ filename,
128
+ sha256: sha,
129
+ bytes,
130
+ }
131
+ await writeManifest(join(destDir, "MANIFEST.json"), manifest)
132
+
133
+ report?.(` ✓ ${(bytes / 1024 / 1024).toFixed(1)} MB sha256=${sha}`)
134
+ fetched++
135
+ }
136
+
137
+ report?.(`=== summary ===`)
138
+ report?.(`fetched: ${fetched}`)
139
+ report?.(`failed: ${failed}`)
140
+
141
+ return { fetched, skipped: 0, failed, failedCodes }
142
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Fetch the full TIGER 2024 ADDRFEAT dataset — all US counties.
7
+ *
8
+ * TIGER ADDRFEAT 2024 source:
9
+ *
10
+ * - https://www2.census.gov/geo/tiger/TIGER2024/ADDRFEAT/
11
+ * - Files: `tl_2024_<statefips><countyfips>_addrfeat.zip`
12
+ *
13
+ * Each state's ZIPs land in `<outRoot>/tiger/addrfeat/state-<statefips>/` with a per-state
14
+ * `MANIFEST.json` recording filename, sha256, and bytes for every county ZIP so re-runs can skip
15
+ * already-verified files. (Extraction + ogr2ogr ingestion happen later, in the `tiger` adapter;
16
+ * this module is download + provenance only.)
17
+ *
18
+ * Invoke via `mailwoman corpus fetch tiger-full --out-root <path>`. Native `fetch` streams each
19
+ * county ZIP to disk (no curl subprocess).
20
+ */
21
+
22
+ import { createWriteStream, existsSync, mkdirSync, rmSync, statSync } from "node:fs"
23
+ import { basename, join } from "node:path"
24
+ import { Readable } from "node:stream"
25
+ import { pipeline } from "node:stream/promises"
26
+ import { setTimeout as sleep } from "node:timers/promises"
27
+
28
+ import { sha256File } from "@mailwoman/core/utils"
29
+
30
+ import type { BaseFetchOptions, FetchSummary } from "./download.ts"
31
+ import { isTransientStatus, readManifest, writeManifest } from "./download.ts"
32
+
33
+ const TIGER_BASE_URL = "https://www2.census.gov/geo/tiger/TIGER2024/ADDRFEAT"
34
+
35
+ export interface FetchTigerFullOptions extends BaseFetchOptions {
36
+ /**
37
+ * Space-separated list of 2-digit state FIPS codes to skip entirely. Default `"50"` — Vermont, already fetched in
38
+ * v0.1.1.
39
+ */
40
+ skipStateFips?: string
41
+ /** Seconds to sleep between downloads. Default `0.2`. */
42
+ rateSleep?: number
43
+ /** Max concurrent download workers per state. Default `4`. */
44
+ maxParallel?: number
45
+ /** Print planned downloads without fetching. Default `false`. */
46
+ dryRun?: boolean
47
+ }
48
+
49
+ interface CountyEntry {
50
+ filename: string
51
+ sha256: string
52
+ bytes: number
53
+ }
54
+
55
+ function humanBytes(bytes: number): string {
56
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"]
57
+ let value = bytes
58
+ let unit = 0
59
+
60
+ while (value >= 1024 && unit < units.length - 1) {
61
+ value /= 1024
62
+ unit++
63
+ }
64
+
65
+ return `${value.toFixed(unit === 0 ? 0 : 1)}${units[unit]}`
66
+ }
67
+
68
+ /**
69
+ * Stream an HTTP download to disk, returning the final HTTP status (0 on network error after retries).
70
+ *
71
+ * NOTE(phase1): kept local instead of the shared `downloadToFile` — this one streams each county ZIP to disk (the
72
+ * shared util buffers via `arrayBuffer()`) and returns the HTTP status instead of throwing, which the per-county result
73
+ * collector consumes.
74
+ */
75
+ async function streamDownload(
76
+ url: string,
77
+ dest: string,
78
+ opts: { timeoutMs: number; retries: number; retryDelayMs: number }
79
+ ): Promise<number> {
80
+ for (let attempt = 0; attempt <= opts.retries; attempt++) {
81
+ try {
82
+ const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(opts.timeoutMs) })
83
+
84
+ if (res.ok && res.body) {
85
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest))
86
+
87
+ return res.status
88
+ }
89
+
90
+ if (attempt < opts.retries && isTransientStatus(res.status)) {
91
+ await sleep(opts.retryDelayMs)
92
+ continue
93
+ }
94
+
95
+ return res.status
96
+ } catch {
97
+ if (attempt < opts.retries) {
98
+ await sleep(opts.retryDelayMs)
99
+ continue
100
+ }
101
+
102
+ return 0
103
+ }
104
+ }
105
+
106
+ return 0
107
+ }
108
+
109
+ /** Read a per-state MANIFEST.json into a filename → entry map. */
110
+ async function readCountyManifest(manifestPath: string): Promise<Map<string, CountyEntry>> {
111
+ const map = new Map<string, CountyEntry>()
112
+ const parsed = await readManifest<{ counties?: CountyEntry[] }>(manifestPath)
113
+
114
+ for (const c of parsed?.counties ?? []) {
115
+ if (c.filename) {
116
+ map.set(c.filename, { filename: c.filename, sha256: c.sha256, bytes: c.bytes })
117
+ }
118
+ }
119
+
120
+ return map
121
+ }
122
+
123
+ /** Check whether a file already matches a recorded sha256 and byte count. */
124
+ async function fileMatchesSha(path: string, expectedSha: string, expectedBytes: number): Promise<boolean> {
125
+ if (!existsSync(path)) return false
126
+
127
+ if (statSync(path).size !== expectedBytes) return false
128
+
129
+ return (await sha256File(path)) === expectedSha
130
+ }
131
+
132
+ type CountyResult =
133
+ | { ok: true; filename: string; sha256: string; bytes: number }
134
+ | { ok: false; filename: string; reason: string }
135
+
136
+ /** Download one county ZIP (size sanity check + sha256). */
137
+ async function downloadCounty(url: string, dest: string): Promise<CountyResult> {
138
+ const filename = basename(dest)
139
+ const status = await streamDownload(url, dest, { timeoutMs: 600_000, retries: 3, retryDelayMs: 5_000 })
140
+
141
+ if (status < 200 || status >= 300) {
142
+ return { ok: false, filename, reason: `HTTP ${status}` }
143
+ }
144
+
145
+ const bytes = statSync(dest).size
146
+
147
+ if (bytes < 1024) {
148
+ rmSync(dest, { force: true })
149
+
150
+ return { ok: false, filename, reason: `too small (${bytes} bytes)` }
151
+ }
152
+
153
+ const sha256 = await sha256File(dest)
154
+
155
+ return { ok: true, filename, sha256, bytes }
156
+ }
157
+
158
+ export async function fetchTigerFull(
159
+ options: FetchTigerFullOptions,
160
+ report?: (line: string) => void
161
+ ): Promise<FetchSummary> {
162
+ const skipStateFips = (options.skipStateFips ?? "50").split(/\s+/).filter(Boolean)
163
+ const rateSleepMs = Math.round((options.rateSleep ?? 0.2) * 1000)
164
+ const maxParallel = options.maxParallel ?? 4
165
+ const dryRun = options.dryRun ?? false
166
+
167
+ const addrfeatDir = join(options.outRoot, "tiger", "addrfeat")
168
+ mkdirSync(addrfeatDir, { recursive: true })
169
+
170
+ // -------------------------------------------------------------------------
171
+ // Step 1: Discover the full county file list from the TIGER directory listing.
172
+ // -------------------------------------------------------------------------
173
+ report?.(`=== Fetching TIGER 2024 ADDRFEAT directory listing...`)
174
+ const listingRes = await fetch(`${TIGER_BASE_URL}/`, {
175
+ headers: { "Accept-Encoding": "gzip, br" },
176
+ signal: AbortSignal.timeout(60_000),
177
+ })
178
+
179
+ if (!listingRes.ok) throw new Error(`Failed to fetch TIGER directory listing: HTTP ${listingRes.status}`)
180
+ const html = await listingRes.text()
181
+ const allZips = [...new Set(html.match(/tl_2024_[0-9]{5}_addrfeat\.zip/g) ?? [])].sort()
182
+ const totalCounties = allZips.length
183
+ report?.(` Found ${totalCounties} county ZIPs in the TIGER 2024 ADDRFEAT index.`)
184
+
185
+ // Build a map: state_fips -> list of filenames.
186
+ // tl_2024_SSCCC_addrfeat.zip — SS = 2-digit state FIPS (chars 8-9), CCC = county FIPS.
187
+ const stateFiles = new Map<string, string[]>()
188
+
189
+ for (const fname of allZips) {
190
+ const stateFips = fname.slice(8, 10)
191
+ const list = stateFiles.get(stateFips) ?? []
192
+ list.push(fname)
193
+ stateFiles.set(stateFips, list)
194
+ }
195
+
196
+ report?.(` Spans ${stateFiles.size} state/territory FIPS codes.`)
197
+
198
+ // -------------------------------------------------------------------------
199
+ // Step 2: For each state, download missing/unverified county ZIPs.
200
+ // -------------------------------------------------------------------------
201
+ let totalFetched = 0
202
+ let totalSkipped = 0
203
+ let totalSkippedState = 0
204
+ let totalFailed = 0
205
+ let totalBytesFetched = 0
206
+ const failedCodes: string[] = []
207
+
208
+ // Process states in sorted FIPS order for predictable output.
209
+ const sortedStates = [...stateFiles.keys()].sort()
210
+
211
+ for (const stateFips of sortedStates) {
212
+ const countyFiles = stateFiles.get(stateFips) ?? []
213
+
214
+ // --- Skip entire state if requested ------------------------------------------
215
+ if (skipStateFips.includes(stateFips)) {
216
+ report?.(`--- State ${stateFips} — SKIPPED (in --skip-state-fips, ${countyFiles.length} counties)`)
217
+ totalSkippedState += countyFiles.length
218
+ continue
219
+ }
220
+
221
+ const stateDir = join(addrfeatDir, `state-${stateFips}`)
222
+ mkdirSync(stateDir, { recursive: true })
223
+ const manifestPath = join(stateDir, "MANIFEST.json")
224
+
225
+ // Load existing manifest for O(1) verified-skip lookup.
226
+ const manifest = await readCountyManifest(manifestPath)
227
+
228
+ report?.(`--- State ${stateFips} — ${countyFiles.length} counties`)
229
+
230
+ // Build a list of URLs+dests that need fetching.
231
+ const pending: Array<{ url: string; dest: string }> = []
232
+
233
+ for (const fname of countyFiles) {
234
+ const dest = join(stateDir, fname)
235
+ const url = `${TIGER_BASE_URL}/${fname}`
236
+ const known = manifest.get(fname)
237
+
238
+ // Skip if already verified via MANIFEST.
239
+ if (known && (await fileMatchesSha(dest, known.sha256, known.bytes))) {
240
+ report?.(` skip (verified) ${fname}`)
241
+ totalSkipped++
242
+ continue
243
+ }
244
+
245
+ if (dryRun) {
246
+ report?.(` would fetch: ${url}`)
247
+ totalFetched++
248
+ continue
249
+ }
250
+
251
+ pending.push({ url, dest })
252
+ }
253
+
254
+ if (dryRun) continue
255
+
256
+ if (pending.length === 0) continue
257
+
258
+ // --- Download pending files with bounded parallelism + rate-limit spacing ---
259
+ const results: CountyResult[] = new Array(pending.length)
260
+ let cursor = 0
261
+ const workers = Array.from({ length: Math.min(maxParallel, pending.length) }, async () => {
262
+ while (true) {
263
+ const i = cursor++
264
+
265
+ if (i >= pending.length) return
266
+ const item = pending[i]!
267
+ // Rate-limit: polite spacing before each fetch.
268
+ await sleep(rateSleepMs)
269
+ results[i] = await downloadCounty(item.url, item.dest)
270
+ }
271
+ })
272
+ await Promise.all(workers)
273
+
274
+ // Collect results from this state.
275
+ for (const result of results) {
276
+ if (result.ok) {
277
+ report?.(` ok ${result.filename} ${humanBytes(result.bytes)} sha256=${result.sha256.slice(0, 12)}...`)
278
+ manifest.set(result.filename, { filename: result.filename, sha256: result.sha256, bytes: result.bytes })
279
+ totalFetched++
280
+ totalBytesFetched += result.bytes
281
+ } else {
282
+ report?.(` FAIL ${result.filename} -- ${result.reason}`)
283
+ totalFailed++
284
+ failedCodes.push(result.filename)
285
+ }
286
+ }
287
+
288
+ // Rewrite per-state MANIFEST.json with all known-good counties (sorted for determinism).
289
+ const counties = [...manifest.values()].sort((a, b) => a.filename.localeCompare(b.filename))
290
+ const manifestDoc = {
291
+ state_fips: stateFips,
292
+ updated_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
293
+ tiger_base_url: TIGER_BASE_URL,
294
+ counties,
295
+ }
296
+ await writeManifest(manifestPath, manifestDoc)
297
+ }
298
+
299
+ // -------------------------------------------------------------------------
300
+ // Summary
301
+ // -------------------------------------------------------------------------
302
+ report?.(`=== Summary ===`)
303
+ report?.(` Total counties in index : ${totalCounties}`)
304
+ report?.(` State(s) fully skipped : ${totalSkippedState} (--skip-state-fips "${skipStateFips.join(" ")}")`)
305
+ report?.(` Counties already present : ${totalSkipped}`)
306
+ report?.(` Counties fetched this run : ${totalFetched}`)
307
+ report?.(` Counties failed : ${totalFailed}`)
308
+
309
+ if (totalBytesFetched > 0) {
310
+ report?.(` Bytes fetched this run : ${humanBytes(totalBytesFetched)} (${totalBytesFetched})`)
311
+ }
312
+
313
+ if (totalFailed > 0) {
314
+ report?.(`WARNING: ${totalFailed} download(s) failed. Re-run to retry.`)
315
+ }
316
+
317
+ return { fetched: totalFetched, skipped: totalSkipped + totalSkippedState, failed: totalFailed, failedCodes }
318
+ }