@mailwoman/tiger 8.6.0 → 9.1.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/out/sdk/download.d.ts +32 -0
- package/out/sdk/download.d.ts.map +1 -0
- package/out/sdk/download.js +65 -0
- package/out/sdk/download.js.map +1 -0
- package/out/sdk/fetch.d.ts.map +1 -1
- package/out/sdk/fetch.js +8 -38
- package/out/sdk/fetch.js.map +1 -1
- package/out/sdk/redistricting.d.ts.map +1 -1
- package/out/sdk/redistricting.js +3 -30
- package/out/sdk/redistricting.js.map +1 -1
- package/out/tools/race-dots.d.ts.map +1 -1
- package/out/tools/race-dots.js +3 -6
- package/out/tools/race-dots.js.map +1 -1
- package/out/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/sdk/download.ts +73 -0
- package/sdk/fetch.ts +9 -45
- package/sdk/redistricting.ts +3 -37
- package/tools/race-dots.ts +3 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/tiger",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.1.0",
|
|
4
4
|
"description": "US Census TIGER/Line data processing and analysis.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"census",
|
|
@@ -73,12 +73,12 @@
|
|
|
73
73
|
"test": "node ./out/sdk/TIGERService.test.js"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
|
-
"@mailwoman/core": "
|
|
77
|
-
"@mailwoman/spatial": "
|
|
76
|
+
"@mailwoman/core": "9.1.0",
|
|
77
|
+
"@mailwoman/spatial": "9.1.0",
|
|
78
78
|
"@turf/boolean-contains": "^7.3.5",
|
|
79
79
|
"kysely": "^0.29.4",
|
|
80
80
|
"shapefile-parser": "^1.0.3",
|
|
81
|
-
"spliterator": "^
|
|
81
|
+
"spliterator": "^6.1.0",
|
|
82
82
|
"type-fest": "^5.8.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
package/sdk/download.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Cached archive download + subprocess capture, shared by `fetch.ts` and `redistricting.ts`.
|
|
7
|
+
*
|
|
8
|
+
* RAW `fetch` HERE IS DELIBERATE. `AGENTS.md` requires HTTP clients to extend or instantiate
|
|
9
|
+
* `APIClient`, and that rule is about API REQUESTS — small bodies, repeated calls, rate-limited
|
|
10
|
+
* hosts, where its pacing, bounded retry, response caching and `ResourceError` mapping all earn
|
|
11
|
+
* their keep. This is a multi-gigabyte file transfer streamed straight to disk. Response caching
|
|
12
|
+
* would be nonsense at that size, pacing has nothing to pace (one request), and axios buffers a
|
|
13
|
+
* non-stream response type in memory. The primitive that fits a body this large is the one that
|
|
14
|
+
* never holds it: a web stream piped to a write stream.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process"
|
|
18
|
+
import { createWriteStream, existsSync } from "node:fs"
|
|
19
|
+
import { rename } from "node:fs/promises"
|
|
20
|
+
import { Readable } from "node:stream"
|
|
21
|
+
import { pipeline } from "node:stream/promises"
|
|
22
|
+
|
|
23
|
+
import { verifyZipIntegrity } from "@mailwoman/core/fs/zip"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run a command and resolve its stdout, rejecting with the captured stderr on a non-zero exit.
|
|
27
|
+
*
|
|
28
|
+
* Used for the `unzip -tq` integrity probe below — a shelled-out check rather than a library one because the archives
|
|
29
|
+
* are large and `unzip -t` streams them without materializing the contents.
|
|
30
|
+
*/
|
|
31
|
+
export function runCapture(cmd: string, args: string[]): Promise<string> {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const child = spawn(cmd, args)
|
|
34
|
+
let out = ""
|
|
35
|
+
let err = ""
|
|
36
|
+
child.stdout.on("data", (d) => (out += d))
|
|
37
|
+
child.stderr.on("data", (d) => (err += d))
|
|
38
|
+
child.on("error", reject)
|
|
39
|
+
|
|
40
|
+
child.on("close", (code) =>
|
|
41
|
+
code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 500)}`))
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Download `url` to `dest` unless a VALID copy is already there. Returns `true` when the cache was reused, `false` when
|
|
48
|
+
* a download happened.
|
|
49
|
+
*
|
|
50
|
+
* "Valid" means every member's CRC-32 checks out — an existence check alone is not enough, because an interrupted
|
|
51
|
+
* download leaves a plausible-looking file that fails only much later, inside ogr2ogr. Writes to a `.tmp` sibling and
|
|
52
|
+
* renames, so `dest` is never a partial archive.
|
|
53
|
+
*/
|
|
54
|
+
export async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
|
|
55
|
+
if (existsSync(dest)) {
|
|
56
|
+
try {
|
|
57
|
+
await verifyZipIntegrity(dest)
|
|
58
|
+
|
|
59
|
+
return true
|
|
60
|
+
} catch {
|
|
61
|
+
// corrupt cache — re-download
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const tmp = dest + ".tmp"
|
|
66
|
+
const res = await fetch(url, { redirect: "follow" })
|
|
67
|
+
|
|
68
|
+
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} fetching ${url}`)
|
|
69
|
+
await pipeline(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmp))
|
|
70
|
+
await rename(tmp, dest)
|
|
71
|
+
|
|
72
|
+
return false
|
|
73
|
+
}
|
package/sdk/fetch.ts
CHANGED
|
@@ -32,10 +32,13 @@ import { DatabaseSync } from "node:sqlite"
|
|
|
32
32
|
import { Readable } from "node:stream"
|
|
33
33
|
import { pipeline } from "node:stream/promises"
|
|
34
34
|
|
|
35
|
+
import { extractZipEntries } from "@mailwoman/core/fs/zip"
|
|
35
36
|
import { DatabaseClient } from "@mailwoman/core/kysley/client"
|
|
37
|
+
import { tryParsingJSON } from "@mailwoman/core/objects"
|
|
36
38
|
import { mailwomanDataRoot } from "@mailwoman/core/utils"
|
|
37
39
|
import { TextSpliterator } from "spliterator"
|
|
38
40
|
|
|
41
|
+
import { downloadIfNeeded } from "./download.ts"
|
|
39
42
|
import type { TIGERBlockTable, TIGERDatabase, TIGERPlaceTable, TIGERStreetTable } from "./schema.ts"
|
|
40
43
|
import { initializeTIGERSchema, TIGER_PRAGMAS } from "./schema.ts"
|
|
41
44
|
|
|
@@ -169,42 +172,6 @@ function buildRow(level: TIGERFetchLevel, p: Record<string, unknown>, geometry:
|
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
function runCapture(cmd: string, args: string[]): Promise<string> {
|
|
173
|
-
return new Promise((resolve, reject) => {
|
|
174
|
-
const child = spawn(cmd, args)
|
|
175
|
-
let out = ""
|
|
176
|
-
let err = ""
|
|
177
|
-
child.stdout.on("data", (d) => (out += d))
|
|
178
|
-
child.stderr.on("data", (d) => (err += d))
|
|
179
|
-
child.on("error", reject)
|
|
180
|
-
|
|
181
|
-
child.on("close", (code) =>
|
|
182
|
-
code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 500)}`))
|
|
183
|
-
)
|
|
184
|
-
})
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
|
|
188
|
-
if (existsSync(dest)) {
|
|
189
|
-
try {
|
|
190
|
-
await runCapture("unzip", ["-tq", dest])
|
|
191
|
-
|
|
192
|
-
return true
|
|
193
|
-
} catch {
|
|
194
|
-
// corrupt cache — re-download
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const tmp = dest + ".tmp"
|
|
199
|
-
const res = await fetch(url, { redirect: "follow" })
|
|
200
|
-
|
|
201
|
-
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} fetching ${url}`)
|
|
202
|
-
await pipeline(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmp))
|
|
203
|
-
await rename(tmp, dest)
|
|
204
|
-
|
|
205
|
-
return false
|
|
206
|
-
}
|
|
207
|
-
|
|
208
175
|
/**
|
|
209
176
|
* Scrape the ADDRFEAT directory listing for a state's county FIPS codes.
|
|
210
177
|
*/
|
|
@@ -304,7 +271,7 @@ export async function* fetchTIGER(options: FetchTIGEROptions): AsyncGenerator<Fe
|
|
|
304
271
|
const cached = await downloadIfNeeded(url, zipPath)
|
|
305
272
|
yield { phase: "download", file: zipName, cached }
|
|
306
273
|
|
|
307
|
-
await
|
|
274
|
+
await extractZipEntries(zipPath, cacheDir)
|
|
308
275
|
const layer = `tl_${vintage}_${unit}_${level}`
|
|
309
276
|
const shpPath = join(cacheDir, layer + ".shp")
|
|
310
277
|
yield { phase: "extract", file: layer + ".shp" }
|
|
@@ -331,17 +298,14 @@ export async function* fetchTIGER(options: FetchTIGEROptions): AsyncGenerator<Fe
|
|
|
331
298
|
child.on("close", (code) => resolve(code ?? 0))
|
|
332
299
|
})
|
|
333
300
|
|
|
334
|
-
// GeoJSONSeq is line-delimited;
|
|
335
|
-
//
|
|
301
|
+
// GeoJSONSeq is line-delimited; per-line `tryParsingJSON` so a malformed record is
|
|
302
|
+
// tolerated (skipped), not thrown.
|
|
336
303
|
for await (const line of TextSpliterator.fromAsync(child.stdout)) {
|
|
337
304
|
if (!line) continue
|
|
338
|
-
let feat: { properties?: Record<string, unknown>; geometry?: unknown }
|
|
339
305
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
continue
|
|
344
|
-
}
|
|
306
|
+
const feat = tryParsingJSON<{ properties?: Record<string, unknown>; geometry?: unknown }>(line)
|
|
307
|
+
|
|
308
|
+
if (!feat) continue
|
|
345
309
|
|
|
346
310
|
if (!feat.properties) continue
|
|
347
311
|
|
package/sdk/redistricting.ts
CHANGED
|
@@ -24,11 +24,13 @@ import { DatabaseSync } from "node:sqlite"
|
|
|
24
24
|
import { Readable } from "node:stream"
|
|
25
25
|
import { pipeline } from "node:stream/promises"
|
|
26
26
|
|
|
27
|
+
import { extractZipEntries } from "@mailwoman/core/fs/zip"
|
|
27
28
|
import { DatabaseClient } from "@mailwoman/core/kysley/client"
|
|
28
29
|
import { mailwomanDataRoot } from "@mailwoman/core/utils"
|
|
29
30
|
import { TextSpliterator } from "spliterator"
|
|
30
31
|
|
|
31
32
|
import { AdminLevel1CodeToAbbreviation, StateName, type AdminLevel1Code } from "../state.ts"
|
|
33
|
+
import { downloadIfNeeded } from "./download.ts"
|
|
32
34
|
import { initializeTIGERSchema, TIGER_PRAGMAS, type PLBlockTable, type TIGERDatabase } from "./schema.ts"
|
|
33
35
|
|
|
34
36
|
const REDISTRICTING_BASE =
|
|
@@ -104,42 +106,6 @@ export interface FetchRedistrictingResult {
|
|
|
104
106
|
inserted: number
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
function runCapture(cmd: string, args: string[]): Promise<string> {
|
|
108
|
-
return new Promise((resolve, reject) => {
|
|
109
|
-
const child = spawn(cmd, args)
|
|
110
|
-
let out = ""
|
|
111
|
-
let err = ""
|
|
112
|
-
child.stdout.on("data", (d) => (out += d))
|
|
113
|
-
child.stderr.on("data", (d) => (err += d))
|
|
114
|
-
child.on("error", reject)
|
|
115
|
-
|
|
116
|
-
child.on("close", (code) =>
|
|
117
|
-
code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 500)}`))
|
|
118
|
-
)
|
|
119
|
-
})
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
|
|
123
|
-
if (existsSync(dest)) {
|
|
124
|
-
try {
|
|
125
|
-
await runCapture("unzip", ["-tq", dest])
|
|
126
|
-
|
|
127
|
-
return true
|
|
128
|
-
} catch {
|
|
129
|
-
// corrupt cache — re-download
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const tmp = dest + ".tmp"
|
|
134
|
-
const res = await fetch(url, { redirect: "follow" })
|
|
135
|
-
|
|
136
|
-
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} fetching ${url}`)
|
|
137
|
-
await pipeline(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmp))
|
|
138
|
-
await rename(tmp, dest)
|
|
139
|
-
|
|
140
|
-
return false
|
|
141
|
-
}
|
|
142
|
-
|
|
143
109
|
// Pipe-delimited, fixed field-offset census rows — the manual `split("|")` at each call site stays (the
|
|
144
110
|
// parse indexes by position, not by header). spliterator keeps CRLF's trailing CR where readline stripped
|
|
145
111
|
// it, but every field this parser reads (geo GEOCODE/LOGRECNO ≤ 9, segment-1 LOGRECNO + P2 ≤ 86) sits well
|
|
@@ -183,7 +149,7 @@ export async function* fetchRedistricting(
|
|
|
183
149
|
const cached = await downloadIfNeeded(url, zipPath)
|
|
184
150
|
yield { phase: "download", file: zipName, cached }
|
|
185
151
|
|
|
186
|
-
await
|
|
152
|
+
await extractZipEntries(zipPath, cacheDir)
|
|
187
153
|
const geoPath = join(cacheDir, `${fileAbbr}geo${vintage}.pl`)
|
|
188
154
|
const seg1Path = join(cacheDir, `${fileAbbr}00001${vintage}.pl`)
|
|
189
155
|
yield { phase: "extract", file: `${fileAbbr}geo${vintage}.pl` }
|
package/tools/race-dots.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { createWriteStream } from "node:fs"
|
|
25
25
|
import { DatabaseSync } from "node:sqlite"
|
|
26
26
|
|
|
27
|
+
import { tryParsingJSON } from "@mailwoman/core/objects"
|
|
27
28
|
import { dataRootPath } from "@mailwoman/core/utils"
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -168,13 +169,9 @@ export async function raceDots(
|
|
|
168
169
|
skipped = 0
|
|
169
170
|
|
|
170
171
|
for (const row of rows) {
|
|
171
|
-
|
|
172
|
+
const geom = tryParsingJSON<{ type: string; coordinates: unknown }>(row.geometry)
|
|
172
173
|
|
|
173
|
-
|
|
174
|
-
geom = JSON.parse(row.geometry)
|
|
175
|
-
} catch {
|
|
176
|
-
continue
|
|
177
|
-
}
|
|
174
|
+
if (!geom) continue
|
|
178
175
|
|
|
179
176
|
const polys: PolygonCoords[] =
|
|
180
177
|
geom.type === "Polygon" ? [geom.coordinates as PolygonCoords] : (geom.coordinates as PolygonCoords[])
|