@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,380 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Fetch an OpenAddresses country collection from batch.openaddresses.io.
7
+ *
8
+ * Source: https://batch.openaddresses.io
9
+ * License: MIXED — OpenAddresses aggregates hundreds of upstream sources with per-source licenses
10
+ * (CC-BY, CC0, PDDL, ODbL, CC-BY-SA, and proprietary attribution-only). The per-row LICENSE filter
11
+ * in the openaddresses adapter is essential for proprietary-weights training: Tier-C rows (ODbL,
12
+ * CC-BY-SA, CC-SA) are dropped at ingest by default. This module downloads the raw collection; the
13
+ * adapter does the license gating.
14
+ *
15
+ * Native `fetch` streams the download to disk (no curl + Python subprocess tax);
16
+ * `node:child_process` keeps the genuine shell ops it still needs (`file` magic detection +
17
+ * `gunzip` decompression, both nice/ionice-deprioritized).
18
+ *
19
+ * ## Authentication note (2026-05-18)
20
+ *
21
+ * The batch.openaddresses.io download endpoint now requires a registered account. Downloads are
22
+ * still free at the "basic" tier (GeoJSON+LD output).
23
+ *
24
+ * 1. Register at https://batch.openaddresses.io/register
25
+ * 2. Log in and go to Profile → "Create Token"
26
+ * 3. Export the token: `export OA_BATCH_TOKEN=<your-token>`
27
+ * 4. Re-run the command.
28
+ *
29
+ * The collection URL pattern (verified 2026-05-18):
30
+ *
31
+ * - `POST /api/login {username, password}` → `{token}`
32
+ * - `GET /api/job/{job_id}/output/source.geojson.gz?token={token}`
33
+ *
34
+ * Collections are downloaded as a combined GeoJSON.gz via:
35
+ *
36
+ * - `GET /api/collections/{collection_id}/download` (returns a redirect to S3)
37
+ *
38
+ * Collection IDs discovered from `/api/collections`:
39
+ *
40
+ * - `id=6 name="ca" size=2044467556` (~1.9 GiB uncompressed, verified 2026-05-18)
41
+ *
42
+ * ## Usage
43
+ *
44
+ * ```sh
45
+ * # With token (preferred). Default country: ca. Supports any OA country code (us-west, fr, …)
46
+ * OA_BATCH_TOKEN=<token> mailwoman corpus fetch openaddresses --country ca \
47
+ * --out-root /mnt/playpen/mailwoman-data/corpus/sources
48
+ *
49
+ * # Without token (will detect + print instructions, then report the failure):
50
+ * mailwoman corpus fetch openaddresses --country ca
51
+ * ```
52
+ */
53
+
54
+ import { execFile, spawn } from "node:child_process"
55
+ import { createReadStream, createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs"
56
+ import { join } from "node:path"
57
+ import { Readable } from "node:stream"
58
+ import { pipeline } from "node:stream/promises"
59
+ import { setTimeout as sleep } from "node:timers/promises"
60
+ import { promisify } from "node:util"
61
+
62
+ import { $private } from "@mailwoman/core/env"
63
+ import { sha256File } from "@mailwoman/core/utils"
64
+
65
+ import type { BaseFetchOptions, FetchSummary } from "./download.ts"
66
+ import { isTransientStatus, writeManifest } from "./download.ts"
67
+
68
+ const execFileAsync = promisify(execFile)
69
+
70
+ const OA_BASE = "https://batch.openaddresses.io"
71
+
72
+ /**
73
+ * Collection IDs known as of 2026-05-18 (discovered via `GET /api/collections`). OA assigns stable integer IDs to each
74
+ * country collection; re-check `GET /api/collections` if a new country is needed and the ID is unknown.
75
+ */
76
+ const OA_COLLECTION_IDS: Record<string, number> = {
77
+ ca: 6,
78
+ "us-west": 4,
79
+ "us-south": 3,
80
+ "us-northeast": 2,
81
+ "us-midwest": 5,
82
+ global: 1,
83
+ }
84
+
85
+ export interface FetchOpenAddressesOptions extends BaseFetchOptions {
86
+ /** OA country collection code. Default `ca`. */
87
+ country?: string
88
+ }
89
+
90
+ interface OaCollection {
91
+ name?: string
92
+ id?: number
93
+ human?: string
94
+ size?: number
95
+ }
96
+
97
+ /** Stream-count newlines, matching `wc -l` (memory-safe for the multi-GB collection). */
98
+ async function countLines(path: string): Promise<number> {
99
+ let count = 0
100
+
101
+ for await (const chunk of createReadStream(path) as AsyncIterable<Buffer>) {
102
+ for (let i = 0; i < chunk.length; i++) {
103
+ if (chunk[i] === 0x0a) {
104
+ count++
105
+ }
106
+ }
107
+ }
108
+
109
+ return count
110
+ }
111
+
112
+ function humanBytes(bytes: number): string {
113
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"]
114
+ let value = bytes
115
+ let unit = 0
116
+
117
+ while (value >= 1024 && unit < units.length - 1) {
118
+ value /= 1024
119
+ unit++
120
+ }
121
+
122
+ return `${value.toFixed(unit === 0 ? 0 : 1)}${units[unit]}`
123
+ }
124
+
125
+ interface StreamDownloadOpts {
126
+ headers?: Record<string, string>
127
+ timeoutMs: number
128
+ retries: number
129
+ retryDelayMs: number
130
+ }
131
+
132
+ /**
133
+ * Stream an HTTP download to disk, returning the final HTTP status (0 on network error after retries). Follows
134
+ * redirects (the OA download endpoint 302s to a pre-signed S3 URL).
135
+ *
136
+ * NOTE(phase1): kept local instead of the shared `downloadToFile` — this one streams a multi-GB body to disk (the
137
+ * shared util buffers via `arrayBuffer()`) and returns the HTTP status instead of throwing, which the caller needs for
138
+ * its two-URL fallback ladder.
139
+ */
140
+ async function streamDownload(url: string, dest: string, opts: StreamDownloadOpts): Promise<number> {
141
+ for (let attempt = 0; attempt <= opts.retries; attempt++) {
142
+ try {
143
+ const res = await fetch(url, {
144
+ headers: opts.headers ?? {},
145
+ redirect: "follow",
146
+ signal: AbortSignal.timeout(opts.timeoutMs),
147
+ })
148
+
149
+ if (res.ok && res.body) {
150
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest))
151
+
152
+ return res.status
153
+ }
154
+
155
+ if (attempt < opts.retries && isTransientStatus(res.status)) {
156
+ await sleep(opts.retryDelayMs)
157
+ continue
158
+ }
159
+
160
+ return res.status
161
+ } catch {
162
+ if (attempt < opts.retries) {
163
+ await sleep(opts.retryDelayMs)
164
+ continue
165
+ }
166
+
167
+ return 0
168
+ }
169
+ }
170
+
171
+ return 0
172
+ }
173
+
174
+ /** Decompress `src` → `dest` with the same deprioritized subprocess the old fetcher used. */
175
+ async function gunzipToFile(src: string, dest: string): Promise<void> {
176
+ const child = spawn("nice", ["-n", "15", "ionice", "-c", "3", "gunzip", "-c", src], {
177
+ stdio: ["ignore", "pipe", "inherit"],
178
+ })
179
+ await pipeline(child.stdout!, createWriteStream(dest))
180
+ await new Promise<void>((resolve, reject) => {
181
+ child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`gunzip exited with code ${code}`))))
182
+ child.on("error", reject)
183
+ })
184
+ }
185
+
186
+ export async function fetchOpenAddresses(
187
+ options: FetchOpenAddressesOptions,
188
+ report?: (line: string) => void
189
+ ): Promise<FetchSummary> {
190
+ const country = options.country ?? "ca"
191
+ const token = $private.OA_BATCH_TOKEN
192
+
193
+ const destDir = join(options.outRoot, "openaddresses", country)
194
+ const manifestPath = join(destDir, "MANIFEST.json")
195
+ const outputFile = join(destDir, "collection.geojsonl")
196
+
197
+ const fail = (code: string): FetchSummary => ({ fetched: 0, skipped: 0, failed: 1, failedCodes: [code] })
198
+
199
+ report?.(`=== fetch openaddresses: country=${country}`)
200
+ report?.(` dest: ${destDir}`)
201
+
202
+ mkdirSync(destDir, { recursive: true })
203
+
204
+ // -------------------------------------------------------------------------
205
+ // Authentication check
206
+ // -------------------------------------------------------------------------
207
+ if (!token) {
208
+ report?.(`
209
+ ERROR: OA_BATCH_TOKEN is not set.
210
+
211
+ As of 2026-05-18, batch.openaddresses.io requires a registered (free) account
212
+ to download collection files. Data remains openly licensed — the auth gate
213
+ is there to prevent CDN abuse, not to restrict access.
214
+
215
+ Steps to get a token:
216
+ 1. Register at: https://batch.openaddresses.io/register
217
+ 2. Verify your email and log in.
218
+ 3. Go to Profile → "Create Token" → copy the token.
219
+ 4. Export it in this shell:
220
+ export OA_BATCH_TOKEN=<your-token>
221
+ 5. Re-run this command.
222
+
223
+ The Canada collection (ca) is ~2 GiB compressed / ~7 GiB uncompressed
224
+ (estimated), so budget ~20–45 minutes at typical cloud-to-host bandwidth.
225
+ `)
226
+
227
+ return fail("OA_BATCH_TOKEN")
228
+ }
229
+
230
+ // -------------------------------------------------------------------------
231
+ // Determine collection ID
232
+ // -------------------------------------------------------------------------
233
+ let collectionID = OA_COLLECTION_IDS[country]
234
+
235
+ if (collectionID === undefined) {
236
+ report?.(`Unknown country code '${country}'. Fetching collection list to find ID...`)
237
+ const res = await fetch(`${OA_BASE}/api/collections`, {
238
+ headers: { Authorization: `Bearer ${token}`, "Accept-Encoding": "gzip, br" },
239
+ signal: AbortSignal.timeout(30_000),
240
+ })
241
+
242
+ if (!res.ok) {
243
+ report?.(`ERROR: GET /api/collections returned HTTP ${res.status}.`)
244
+
245
+ return fail(country)
246
+ }
247
+
248
+ const collections = (await res.json()) as OaCollection[]
249
+ const match = collections.find((item) => item.name === country)
250
+
251
+ if (match?.id === undefined) {
252
+ report?.(`ERROR: Could not find a collection named '${country}' in GET /api/collections.`)
253
+ report?.(`Available collections:`)
254
+
255
+ for (const item of collections) {
256
+ const size = (item.size ?? 0).toLocaleString()
257
+ report?.(` ${(item.name ?? "").padEnd(20)} id=${item.id} ${item.human ?? ""} size=${size} bytes`)
258
+ }
259
+
260
+ return fail(country)
261
+ }
262
+
263
+ collectionID = match.id
264
+ report?.(` Found collection id=${collectionID} for '${country}'`)
265
+ }
266
+
267
+ // -------------------------------------------------------------------------
268
+ // Download via the collections download endpoint (302s to S3)
269
+ // -------------------------------------------------------------------------
270
+ report?.(` Resolving download URL for collection id=${collectionID}...`)
271
+ report?.(` Attempting authenticated download...`)
272
+
273
+ const tmpGz = join(destDir, "collection.geojsonl.gz.tmp")
274
+ const tmpRaw = join(destDir, "collection.geojsonl.tmp")
275
+ const sourceURL = `${OA_BASE}/api/collections/${collectionID}/download`
276
+
277
+ let httpStatus = await streamDownload(sourceURL, tmpGz, {
278
+ headers: { Authorization: `Bearer ${token}` },
279
+ timeoutMs: 7_200_000,
280
+ retries: 3,
281
+ retryDelayMs: 30_000,
282
+ })
283
+
284
+ if (httpStatus !== 200) {
285
+ // Try the geojsonl.gz directly with token as query param (alternate URL shape).
286
+ httpStatus = await streamDownload(`${OA_BASE}/api/collections/${collectionID}/geojsonl.gz?token=${token}`, tmpGz, {
287
+ timeoutMs: 7_200_000,
288
+ retries: 3,
289
+ retryDelayMs: 30_000,
290
+ })
291
+ }
292
+
293
+ if (httpStatus !== 200) {
294
+ rmSync(tmpGz, { force: true })
295
+ report?.(`
296
+ ERROR: Download returned HTTP ${httpStatus}.
297
+
298
+ Likely causes:
299
+ 1. OA_BATCH_TOKEN is invalid or expired — re-create it at Profile → Tokens.
300
+ 2. The collection download endpoint URL has changed (this module was written
301
+ against the 2026-05-18 batch.openaddresses.io API; it may need updating).
302
+ 3. Network error or CDN outage.
303
+
304
+ Manual download (after logging in to batch.openaddresses.io):
305
+ - Navigate to https://batch.openaddresses.io/collection/${collectionID}
306
+ - Click "GeoJSON+LD" to download the collection.
307
+ - Save as: ${outputFile}
308
+
309
+ URL tried: ${OA_BASE}/api/collections/${collectionID}/download
310
+ `)
311
+
312
+ return fail(country)
313
+ }
314
+
315
+ // -------------------------------------------------------------------------
316
+ // Decompress if the downloaded file is gzipped
317
+ // -------------------------------------------------------------------------
318
+ const fileMagic = (await execFileAsync("file", ["--brief", tmpGz]).catch(() => ({ stdout: "" }))).stdout
319
+
320
+ if (/gzip|compressed/i.test(fileMagic)) {
321
+ report?.(` Decompressing gzip archive...`)
322
+ await gunzipToFile(tmpGz, tmpRaw)
323
+ rmSync(tmpGz, { force: true })
324
+ renameSync(tmpRaw, outputFile)
325
+ } else if (/JSON|ASCII|UTF-8/i.test(fileMagic)) {
326
+ // Already line-delimited GeoJSON.
327
+ renameSync(tmpGz, outputFile)
328
+ rmSync(tmpRaw, { force: true })
329
+ } else {
330
+ // Unknown type — keep as-is and let the operator inspect.
331
+ renameSync(tmpGz, outputFile)
332
+ report?.(` WARNING: Downloaded file type is '${fileMagic.trim()}' — may need manual decompression.`)
333
+ }
334
+
335
+ // -------------------------------------------------------------------------
336
+ // Verify + write MANIFEST
337
+ // -------------------------------------------------------------------------
338
+ if (!existsSync(outputFile)) {
339
+ report?.(`ERROR: Output file not found at ${outputFile} after download.`)
340
+
341
+ return fail(country)
342
+ }
343
+
344
+ const size = statSync(outputFile).size
345
+
346
+ if (size < 10240) {
347
+ report?.(`ERROR: File is suspiciously small (${size} bytes) — likely an error response.`)
348
+
349
+ return fail(country)
350
+ }
351
+
352
+ const sha = await sha256File(outputFile)
353
+ const rowCount = await countLines(outputFile)
354
+ const downloadedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z")
355
+
356
+ const manifest = {
357
+ source_url: sourceURL,
358
+ collection_id: collectionID,
359
+ country,
360
+ filename: "collection.geojsonl",
361
+ downloaded_at: downloadedAt,
362
+ sha256: sha,
363
+ bytes: size,
364
+ row_count: rowCount,
365
+ notes:
366
+ "batch.openaddresses.io requires a free registered account for downloads. License is mixed per-row; use the openaddresses adapter with allowShareAlike=false (default) to filter Tier-C rows.",
367
+ }
368
+ await writeManifest(manifestPath, manifest)
369
+
370
+ report?.(` ✓ ${humanBytes(size)} rows=${rowCount} sha256=${sha}`)
371
+ report?.(` MANIFEST written to ${manifestPath}`)
372
+ report?.(`=== done`)
373
+ report?.(`Feed to the adapter:`)
374
+ report?.(` mailwoman corpus run openaddresses \\`)
375
+ report?.(` --input ${outputFile} \\`)
376
+ report?.(` --country ${country.toUpperCase()} \\`)
377
+ report?.(` --output ${options.outRoot}`)
378
+
379
+ return { fetched: 1, skipped: 0, failed: 0, failedCodes: [] }
380
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Re-fetch the Hawaii State DOE school directory and convert the XLSX workbook to a flat CSV the
7
+ * `state-hi-schools` adapter can consume.
8
+ *
9
+ * Upstream is a single XLSX (~64 KB) with two sheets — `HIDOE` (~258 district schools) and `PCS`
10
+ * (~38 public charter schools). Both sheets share the same header. This module concatenates them
11
+ * under one shared header so the adapter can stream a single CSV.
12
+ *
13
+ * License: Hawaii state government open data (Tier A — state PD-equivalent).
14
+ *
15
+ * Built-in `fetch` (gzip/brotli) replaces curl for the download; the XLSX → CSV step still rides
16
+ * `python3` + `openpyxl` via `node:child_process` — there is no clean node equivalent without
17
+ * adding a workbook-parsing dependency.
18
+ *
19
+ * Invoke via `mailwoman corpus fetch state-hi-schools --out-root <path>`. Idempotent: if the dest
20
+ * CSV exists and sha matches MANIFEST, skips download.
21
+ */
22
+
23
+ import { spawn, spawnSync } from "node:child_process"
24
+ import { existsSync, mkdirSync, statSync } from "node:fs"
25
+ import { unlink } from "node:fs/promises"
26
+ import { join } from "node:path"
27
+
28
+ import { sha256File } from "@mailwoman/core/utils"
29
+
30
+ import type { BaseFetchOptions, FetchSummary } from "./download.ts"
31
+ import { downloadToFile, readManifest, writeManifest } from "./download.ts"
32
+
33
+ const SOURCE_URL = "https://www.hawaiipublicschools.org/DOE%20Forms/SchoolList.xlsx"
34
+ const SLUG = "state-hi-schools"
35
+ const CSV_FILENAME = "HI_Public_Schools_List.csv"
36
+ const XLSX_FILENAME = "HI_Public_Schools_List.xlsx"
37
+
38
+ export type FetchStateHISchoolsOptions = BaseFetchOptions
39
+
40
+ /**
41
+ * The XLSX → CSV converter: concatenate every sheet under one shared header (the first sheet's). Runs as `python3 -c
42
+ * <script> <xlsx-path> <csv-path>`, so `sys.argv[1]`/`sys.argv[2]` are the I/O paths. TODO: Get rid of this.
43
+ */
44
+ const PY_CONVERT = `
45
+ import csv
46
+ import sys
47
+ from openpyxl import load_workbook
48
+
49
+ xlsx_path, csv_path = sys.argv[1], sys.argv[2]
50
+ wb = load_workbook(xlsx_path, data_only=True, read_only=True)
51
+
52
+ with open(csv_path, "w", newline="", encoding="utf-8") as out:
53
+ writer = csv.writer(out)
54
+ shared_header = None
55
+ total_data_rows = 0
56
+ for sheet_name in wb.sheetnames:
57
+ ws = wb[sheet_name]
58
+ rows = ws.iter_rows(values_only=True)
59
+ try:
60
+ header = next(rows)
61
+ except StopIteration:
62
+ continue
63
+ norm_header = ["" if v is None else str(v).strip() for v in header]
64
+ if shared_header is None:
65
+ shared_header = norm_header
66
+ writer.writerow(shared_header)
67
+ elif norm_header != shared_header:
68
+ print(
69
+ f" ! sheet '{sheet_name}' header diverges from shared header; concatenating anyway",
70
+ file=sys.stderr,
71
+ )
72
+ for row in rows:
73
+ if row is None:
74
+ continue
75
+ # Skip fully-empty rows (XLSX iter_rows can yield phantom trailing rows).
76
+ if all(v is None or (isinstance(v, str) and not v.strip()) for v in row):
77
+ continue
78
+ writer.writerow(["" if v is None else str(v).strip() for v in row])
79
+ total_data_rows += 1
80
+
81
+ print(f" converted {total_data_rows} data rows from {len(wb.sheetnames)} sheets", file=sys.stderr)
82
+ `
83
+
84
+ interface Manifest {
85
+ source_url: string
86
+ downloaded_at: string
87
+ filename: string
88
+ sha256: string
89
+ bytes: number
90
+ notes: string
91
+ }
92
+
93
+ /** Mimic `numfmt --to=iec` for a friendly byte-size log line. */
94
+ function iec(bytes: number): string {
95
+ if (bytes < 1024) return String(bytes)
96
+ const units = ["K", "M", "G", "T", "P"]
97
+ let value = bytes / 1024
98
+ let i = 0
99
+
100
+ while (value >= 1024 && i < units.length - 1) {
101
+ value /= 1024
102
+ i++
103
+ }
104
+
105
+ const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString()
106
+
107
+ return `${rounded}${units[i] ?? ""}`
108
+ }
109
+
110
+ /**
111
+ * Run the openpyxl converter. Its stderr narration streams straight through to the process stderr (matching the old
112
+ * `stdio: inherit` behavior) rather than routing through `report` — the python child owns those lines.
113
+ */
114
+ async function convertXLSXToCSV(xlsxPath: string, csvPath: string): Promise<void> {
115
+ const child = spawn("python3", ["-c", PY_CONVERT, xlsxPath, csvPath], {
116
+ stdio: ["ignore", "inherit", "inherit"],
117
+ })
118
+ await new Promise<void>((resolve, reject) => {
119
+ child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`python3 converter exited with ${code}`))))
120
+ child.on("error", reject)
121
+ })
122
+ }
123
+
124
+ export async function fetchStateHISchools(
125
+ options: FetchStateHISchoolsOptions,
126
+ report?: (line: string) => void
127
+ ): Promise<FetchSummary> {
128
+ const destDir = join(options.outRoot, SLUG)
129
+ mkdirSync(destDir, { recursive: true })
130
+
131
+ const xlsxDest = join(destDir, XLSX_FILENAME)
132
+ const csvDest = join(destDir, CSV_FILENAME)
133
+ const manifestPath = join(destDir, "MANIFEST.json")
134
+
135
+ report?.(`=== ${SLUG}`)
136
+
137
+ // Idempotency: skip if CSV exists and sha matches recorded MANIFEST.
138
+ if (existsSync(csvDest)) {
139
+ const recorded = await readManifest<Partial<Manifest>>(manifestPath)
140
+
141
+ if (recorded?.sha256 && recorded.filename === CSV_FILENAME) {
142
+ const actualSha = await sha256File(csvDest)
143
+
144
+ if (actualSha === recorded.sha256) {
145
+ report?.(` ✓ Already current (sha256 matches MANIFEST) — skipping download.`)
146
+
147
+ return { fetched: 0, skipped: 1, failed: 0, failedCodes: [] }
148
+ }
149
+ }
150
+ }
151
+
152
+ // Preflight: openpyxl must be importable.
153
+ const preflight = spawnSync("python3", ["-c", "import openpyxl"], { stdio: "ignore" })
154
+
155
+ if (preflight.status !== 0) {
156
+ report?.(
157
+ ` ✗ python3 with the \`openpyxl\` package is required to convert the HIDOE XLSX.\n` +
158
+ ` Debian/Ubuntu: sudo apt-get install -y python3-openpyxl\n` +
159
+ ` macOS Homebrew: brew install python && pip3 install openpyxl`
160
+ )
161
+
162
+ return { fetched: 0, skipped: 0, failed: 1, failedCodes: [SLUG] }
163
+ }
164
+
165
+ // Download XLSX.
166
+ report?.(` Downloading ${SOURCE_URL} ...`)
167
+
168
+ try {
169
+ await downloadToFile({
170
+ url: SOURCE_URL,
171
+ dest: xlsxDest,
172
+ timeoutMs: 600_000,
173
+ headers: { "Accept-Encoding": "gzip, br" },
174
+ report,
175
+ })
176
+ } catch (err) {
177
+ report?.(` ✗ Download failed (${(err as Error).message})`)
178
+
179
+ return { fetched: 0, skipped: 0, failed: 1, failedCodes: [SLUG] }
180
+ }
181
+
182
+ const xlsxSize = statSync(xlsxDest).size
183
+ report?.(` Downloaded XLSX: ${iec(xlsxSize)}`)
184
+
185
+ if (xlsxSize < 1024) {
186
+ report?.(` ✗ Response too small (${xlsxSize} bytes) — probable error page`)
187
+
188
+ return { fetched: 0, skipped: 0, failed: 1, failedCodes: [SLUG] }
189
+ }
190
+
191
+ // Convert XLSX → CSV (concatenate both sheets under one shared header).
192
+ report?.(` Converting XLSX → CSV (concatenating sheets) ...`)
193
+ await convertXLSXToCSV(xlsxDest, csvDest)
194
+
195
+ const csvSize = statSync(csvDest).size
196
+ const csvSha = await sha256File(csvDest)
197
+
198
+ // Remove XLSX (CSV is the canonical artifact the adapter consumes).
199
+ await unlink(xlsxDest)
200
+ report?.(` Removed XLSX (CSV kept)`)
201
+
202
+ // Write MANIFEST.
203
+ const manifest: Manifest = {
204
+ source_url: SOURCE_URL,
205
+ downloaded_at: new Date().toISOString(),
206
+ filename: CSV_FILENAME,
207
+ sha256: csvSha,
208
+ bytes: csvSize,
209
+ notes: "Converted from XLSX (sheets HIDOE + PCS concatenated under shared header).",
210
+ }
211
+ await writeManifest(manifestPath, manifest)
212
+
213
+ report?.(` ✓ ${iec(csvSize)} sha256=${csvSha}`)
214
+ report?.(` MANIFEST written to ${manifestPath}`)
215
+
216
+ return { fetched: 1, skipped: 0, failed: 0, failedCodes: [] }
217
+ }