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