@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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The `mailwoman corpus fetch <source>` family — reproducible bulk-download recovery modules for
|
|
7
|
+
* the open-data sources the corpus build pipeline consumes. Each module writes the raw download
|
|
8
|
+
* files **plus** a sibling `MANIFEST.json` capturing the origin URL, fetch timestamp, byte count,
|
|
9
|
+
* and sha256 so downstream adapters can verify provenance.
|
|
10
|
+
*
|
|
11
|
+
* The corpus build pipeline itself does NOT call these modules — the existing convention is for
|
|
12
|
+
* operators to pre-download into an out-root and point adapters at the resulting files. These
|
|
13
|
+
* modules exist for **reproducibility** (disk-loss recovery, weekly refresh, fresh-environment
|
|
14
|
+
* bootstrap).
|
|
15
|
+
*
|
|
16
|
+
* ## Usage
|
|
17
|
+
*
|
|
18
|
+
* ```sh
|
|
19
|
+
* # Default: writes under ./data/corpus/sources/ relative to the working directory
|
|
20
|
+
* mailwoman corpus fetch state-sources
|
|
21
|
+
* mailwoman corpus fetch hrsa
|
|
22
|
+
*
|
|
23
|
+
* # Or point at the standard mailwoman data root
|
|
24
|
+
* mailwoman corpus fetch state-sources --out-root /data/corpus/sources
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Each adapter under `corpus/src/adapters/<adapter>/README.md` documents the specific URL its
|
|
28
|
+
* input was pulled from; these modules mirror those URLs in a single executable place.
|
|
29
|
+
*
|
|
30
|
+
* ## Coverage
|
|
31
|
+
*
|
|
32
|
+
* - `ban` — French BAN (Base Adresse Nationale), all départements incl. DOM/TOM. Tier B (Licence
|
|
33
|
+
* Ouverte 2.0).
|
|
34
|
+
* - `nad` — US DOT National Address Database (~97M address points, ArcGIS FeatureServer). Tier A
|
|
35
|
+
* (US PD).
|
|
36
|
+
* - `hrsa` — HRSA Health Center Service Delivery Sites (federal). Tier A (US PD).
|
|
37
|
+
* - `imls-pls` — IMLS Public Libraries Survey, outlet-level (~17K library branches, FY 2023).
|
|
38
|
+
* Tier A (US PD).
|
|
39
|
+
* - `nppes` — NPPES NPI registry, full monthly dissemination (~7M provider venue+address rows).
|
|
40
|
+
* Tier A (US PD).
|
|
41
|
+
* - `openaddresses` — OpenAddresses country collections (default: Canada / `ca`). Tier B/C mixed
|
|
42
|
+
* — per-row filter.
|
|
43
|
+
* - `state-sources` — NY/TX/DE/OR notaries, IA contractors, WA health providers, HI lobbyists.
|
|
44
|
+
* Tier A (state PD-equivalent).
|
|
45
|
+
* - `state-hi-schools` — Hawaii DOE school directory (XLSX → CSV via openpyxl). Tier A (state
|
|
46
|
+
* PD-equivalent).
|
|
47
|
+
* - `tiger-full` — US Census TIGER 2024 ADDRFEAT, all US counties. Tier A (US PD).
|
|
48
|
+
*
|
|
49
|
+
* License tiers per `docs/licensing-strategy.md` (or the playpen knowledge base mirror at
|
|
50
|
+
* `docs/docs/projects/mailwoman/licensing-strategy.md`). `openaddresses` is a **Tier-mixed**
|
|
51
|
+
* source: the downloaded collection includes CC0, CC-BY, OGL, and ODbL/CC-BY-SA rows. The per-row
|
|
52
|
+
* `LICENSE` filter in the `openaddresses` adapter is essential — Tier-C (ODbL, CC-BY-SA) rows are
|
|
53
|
+
* dropped at ingest by default to protect proprietary-weights training.
|
|
54
|
+
*
|
|
55
|
+
* ### OpenAddresses authentication (as of 2026-05-18)
|
|
56
|
+
*
|
|
57
|
+
* `batch.openaddresses.io` now requires a free registered account for bulk downloads (auth gate
|
|
58
|
+
* prevents CDN abuse; data remains openly licensed). `fetchOpenAddresses` reads `OA_BATCH_TOKEN`
|
|
59
|
+
* from the environment:
|
|
60
|
+
*
|
|
61
|
+
* ```sh
|
|
62
|
+
* # One-time: register at https://batch.openaddresses.io/register
|
|
63
|
+
* # Log in → Profile → "Create Token" → copy token
|
|
64
|
+
* export OA_BATCH_TOKEN=<your-token>
|
|
65
|
+
*
|
|
66
|
+
* # Download Canada (~2 GiB compressed, ~7 GiB uncompressed)
|
|
67
|
+
* mailwoman corpus fetch openaddresses --country ca \
|
|
68
|
+
* --out-root /mnt/playpen/mailwoman-data/corpus/sources
|
|
69
|
+
*
|
|
70
|
+
* # Or any other OA country code
|
|
71
|
+
* mailwoman corpus fetch openaddresses --country fr
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* Without a token the command prints setup instructions and reports the failure.
|
|
75
|
+
*
|
|
76
|
+
* ## Adding a new source
|
|
77
|
+
*
|
|
78
|
+
* 1. Pick the right module (or create a sibling one if the source is from a meaningfully
|
|
79
|
+
* different family).
|
|
80
|
+
* 2. Append to the `SOURCES` array: `{ slug, filename, url }`.
|
|
81
|
+
* 3. Confirm the destination URL via `curl -sI -L <url> | head` before committing — state
|
|
82
|
+
* open-data portals occasionally rotate Socrata view IDs.
|
|
83
|
+
* 4. Run the command against a scratch `--out-root` to verify the download succeeds + the
|
|
84
|
+
* MANIFEST is well-formed.
|
|
85
|
+
* 5. Add the source's adapter (or extend an existing one) under `corpus/src/adapters/`.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
import { fetchBan } from "./ban.ts"
|
|
89
|
+
import { fetchHRSA } from "./hrsa.ts"
|
|
90
|
+
import { fetchIMLSPLS } from "./imls-pls.ts"
|
|
91
|
+
import { fetchNAD } from "./nad.ts"
|
|
92
|
+
import { fetchNPPES } from "./nppes.ts"
|
|
93
|
+
import { fetchOpenAddresses } from "./openaddresses.ts"
|
|
94
|
+
import { fetchStateHISchools } from "./state-hi-schools.ts"
|
|
95
|
+
import { fetchStateSources } from "./state-sources.ts"
|
|
96
|
+
import { fetchTigerFull } from "./tiger-full.ts"
|
|
97
|
+
|
|
98
|
+
export * from "./ban.ts"
|
|
99
|
+
export * from "./hrsa.ts"
|
|
100
|
+
export * from "./imls-pls.ts"
|
|
101
|
+
export * from "./nad.ts"
|
|
102
|
+
export * from "./nppes.ts"
|
|
103
|
+
export * from "./openaddresses.ts"
|
|
104
|
+
export * from "./state-hi-schools.ts"
|
|
105
|
+
export * from "./state-sources.ts"
|
|
106
|
+
export * from "./tiger-full.ts"
|
|
107
|
+
|
|
108
|
+
/** The fetch-source registry: id → module entry point. Each entry point takes its own options interface. */
|
|
109
|
+
export const FETCH_SOURCES = {
|
|
110
|
+
ban: fetchBan,
|
|
111
|
+
nad: fetchNAD,
|
|
112
|
+
hrsa: fetchHRSA,
|
|
113
|
+
"imls-pls": fetchIMLSPLS,
|
|
114
|
+
nppes: fetchNPPES,
|
|
115
|
+
openaddresses: fetchOpenAddresses,
|
|
116
|
+
"state-sources": fetchStateSources,
|
|
117
|
+
"state-hi-schools": fetchStateHISchools,
|
|
118
|
+
"tiger-full": fetchTigerFull,
|
|
119
|
+
} as const
|
|
120
|
+
|
|
121
|
+
export type FetchSourceID = keyof typeof FETCH_SOURCES
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Fetch the US DOT National Address Database (NAD) — ~97 million structured US address-point
|
|
7
|
+
* records aggregated from state and local authorities. Source for the `usgov-nad` adapter (#30).
|
|
8
|
+
* US Public Domain (17 U.S.C. § 105).
|
|
9
|
+
*
|
|
10
|
+
* - Bounded per-chunk page concurrency (4× speedup at safe pressure)
|
|
11
|
+
* - 5× larger page size (5 000 vs. the old bash fetcher's 2 000) — fewer round-trips per chunk
|
|
12
|
+
* - Honest `complete: true` flag: only set when every page in the chunk fetched cleanly
|
|
13
|
+
* - Built-in fetch with gzip/brotli decompression (no curl + Python subprocess tax)
|
|
14
|
+
* - Per-chunk manifest with sha256 + record count + error count
|
|
15
|
+
*
|
|
16
|
+
* ## Source layout
|
|
17
|
+
*
|
|
18
|
+
* The ArcGIS FeatureServer is the only fully-automated path. As of 2026-05:
|
|
19
|
+
*
|
|
20
|
+
* - **`bulk`** mode requires a pre-signed S3 URL (Akamai blocks scripted curl on the DOT page). Pass
|
|
21
|
+
* `--nad-url <presigned>` from a browser visit to
|
|
22
|
+
* [https://www.transportation.gov/gis/national-address-database](https://www.transportation.gov/gis/national-address-database).
|
|
23
|
+
* - **`featureserver`** mode (default) pages the live FeatureService via OBJECTID ranges, writing
|
|
24
|
+
* NDJSON chunks into `<outRoot>/usgov-nad/featureserver/`.
|
|
25
|
+
*
|
|
26
|
+
* ## Usage
|
|
27
|
+
*
|
|
28
|
+
* ```sh
|
|
29
|
+
* mailwoman corpus fetch nad --out-root /mnt/playpen/mailwoman-data/corpus/sources
|
|
30
|
+
*
|
|
31
|
+
* # Resume from an OID
|
|
32
|
+
* mailwoman corpus fetch nad --start-oid 34400001
|
|
33
|
+
*
|
|
34
|
+
* # Increase concurrency on a fast link
|
|
35
|
+
* mailwoman corpus fetch nad --concurrency 8 --page-size 10000
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { existsSync, mkdirSync, statSync } from "node:fs"
|
|
40
|
+
import { writeFile } from "node:fs/promises"
|
|
41
|
+
import { join } from "node:path"
|
|
42
|
+
|
|
43
|
+
import { sha256File } from "@mailwoman/core/utils"
|
|
44
|
+
|
|
45
|
+
import type { BaseFetchOptions, FetchSummary } from "./download.ts"
|
|
46
|
+
import { downloadToFile, readManifest, writeManifest } from "./download.ts"
|
|
47
|
+
|
|
48
|
+
const SLUG = "usgov-nad"
|
|
49
|
+
const FEATURE_SERVICE_URL =
|
|
50
|
+
"https://services.arcgis.com/xOi1kZaI0eWDREZv/ArcGIS/rest/services/Address_Points_from_National_Address_Database_view/FeatureServer/0"
|
|
51
|
+
|
|
52
|
+
export interface FetchNADOptions extends BaseFetchOptions {
|
|
53
|
+
/** Fetch strategy. Default `featureserver`. */
|
|
54
|
+
mode?: "featureserver" | "bulk"
|
|
55
|
+
/** Pre-signed S3 URL for bulk mode. */
|
|
56
|
+
nadURL?: string
|
|
57
|
+
/** Records per output file. Default `100000`. */
|
|
58
|
+
chunkSize?: number
|
|
59
|
+
/** Records per HTTP request. Default `5000`. */
|
|
60
|
+
pageSize?: number
|
|
61
|
+
/** Parallel page fetches within a chunk. Default `4`. */
|
|
62
|
+
concurrency?: number
|
|
63
|
+
/** Start OBJECTID. Default `1`. */
|
|
64
|
+
startOID?: number
|
|
65
|
+
/** Stop before this OID. Default = total count. */
|
|
66
|
+
endOID?: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ChunkManifest {
|
|
70
|
+
source_url: string
|
|
71
|
+
oid_range: [number, number]
|
|
72
|
+
downloaded_at: string
|
|
73
|
+
filename: string
|
|
74
|
+
sha256: string
|
|
75
|
+
bytes: number
|
|
76
|
+
record_count: number
|
|
77
|
+
page_errors: number
|
|
78
|
+
complete: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fetchPage(startOID: number, endOID: number, pageSize: number): Promise<unknown[]> {
|
|
82
|
+
const url = new URL(`${FEATURE_SERVICE_URL}/query`)
|
|
83
|
+
url.searchParams.set("where", `OBJECTID BETWEEN ${startOID} AND ${endOID}`)
|
|
84
|
+
url.searchParams.set("outFields", "*")
|
|
85
|
+
url.searchParams.set("f", "json")
|
|
86
|
+
url.searchParams.set("resultRecordCount", String(pageSize))
|
|
87
|
+
|
|
88
|
+
const res = await fetch(url, {
|
|
89
|
+
headers: { "Accept-Encoding": "gzip, br" },
|
|
90
|
+
signal: AbortSignal.timeout(120_000),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} on OID ${startOID}-${endOID}`)
|
|
94
|
+
const data = (await res.json()) as { features?: Array<{ attributes: unknown }>; error?: { message: string } }
|
|
95
|
+
|
|
96
|
+
if (data.error) throw new Error(`ArcGIS error on OID ${startOID}-${endOID}: ${data.error.message}`)
|
|
97
|
+
|
|
98
|
+
return (data.features ?? []).map((f) => f.attributes)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function discoverTotalCount(): Promise<number> {
|
|
102
|
+
const url = new URL(`${FEATURE_SERVICE_URL}/query`)
|
|
103
|
+
url.searchParams.set("where", "1=1")
|
|
104
|
+
url.searchParams.set("returnCountOnly", "true")
|
|
105
|
+
url.searchParams.set("f", "json")
|
|
106
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) })
|
|
107
|
+
|
|
108
|
+
if (!res.ok) throw new Error(`Failed to discover NAD record count: HTTP ${res.status}`)
|
|
109
|
+
const data = (await res.json()) as { count?: number }
|
|
110
|
+
|
|
111
|
+
if (typeof data.count !== "number") throw new Error("NAD count query returned no count field")
|
|
112
|
+
|
|
113
|
+
return data.count
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fetch a single chunk by paging through its OID range with bounded concurrency. Returns the count of records written
|
|
118
|
+
* and the count of pages that errored. The caller decides whether to mark the chunk complete based on errors === 0.
|
|
119
|
+
*
|
|
120
|
+
* NOTE(phase1): this is a JSON API pager, not a file download — the shared `downloadToFile` doesn't apply here.
|
|
121
|
+
*/
|
|
122
|
+
async function fetchChunk(
|
|
123
|
+
chunkPath: string,
|
|
124
|
+
chunkStart: number,
|
|
125
|
+
chunkEnd: number,
|
|
126
|
+
pageSize: number,
|
|
127
|
+
concurrency: number,
|
|
128
|
+
report?: (line: string) => void
|
|
129
|
+
): Promise<{ recordCount: number; errors: number }> {
|
|
130
|
+
const pageRanges: Array<[number, number]> = []
|
|
131
|
+
|
|
132
|
+
for (let cursor = chunkStart; cursor <= chunkEnd; cursor += pageSize) {
|
|
133
|
+
pageRanges.push([cursor, Math.min(cursor + pageSize - 1, chunkEnd)])
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Run bounded-concurrency page fetches. Results indexed by page slot for in-order write.
|
|
137
|
+
const pageResults: Array<{ rows: unknown[]; error: Error | null }> = pageRanges.map(() => ({
|
|
138
|
+
rows: [],
|
|
139
|
+
error: null,
|
|
140
|
+
}))
|
|
141
|
+
let nextSlot = 0
|
|
142
|
+
const workers = Array.from({ length: Math.min(concurrency, pageRanges.length) }, async () => {
|
|
143
|
+
while (true) {
|
|
144
|
+
const slot = nextSlot++
|
|
145
|
+
|
|
146
|
+
if (slot >= pageRanges.length) return
|
|
147
|
+
const [s, e] = pageRanges[slot]!
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
pageResults[slot]!.rows = await fetchPage(s, e, pageSize)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
pageResults[slot]!.error = err as Error
|
|
153
|
+
report?.(` ✗ page ${s}-${e}: ${(err as Error).message}`)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
await Promise.all(workers)
|
|
158
|
+
|
|
159
|
+
// Single-writer phase — write all pages in OID order to keep NDJSON deterministic.
|
|
160
|
+
const lines: string[] = []
|
|
161
|
+
let errors = 0
|
|
162
|
+
|
|
163
|
+
for (const { rows, error } of pageResults) {
|
|
164
|
+
if (error) {
|
|
165
|
+
errors++
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
lines.push(JSON.stringify(row))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await writeFile(chunkPath, lines.length === 0 ? "" : lines.join("\n") + "\n")
|
|
174
|
+
|
|
175
|
+
return { recordCount: lines.length, errors }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function featureserverMode(options: FetchNADOptions, report?: (line: string) => void): Promise<FetchSummary> {
|
|
179
|
+
const chunkSize = options.chunkSize ?? 100_000
|
|
180
|
+
const pageSize = options.pageSize ?? 5_000
|
|
181
|
+
const concurrency = options.concurrency ?? 4
|
|
182
|
+
const startOID = options.startOID ?? 1
|
|
183
|
+
|
|
184
|
+
const chunkDir = join(options.outRoot, SLUG, "featureserver")
|
|
185
|
+
mkdirSync(chunkDir, { recursive: true })
|
|
186
|
+
|
|
187
|
+
report?.(`=== ${SLUG} / featureserver`)
|
|
188
|
+
report?.(` Discovering record count ...`)
|
|
189
|
+
const totalCount = await discoverTotalCount()
|
|
190
|
+
const endOID = options.endOID ?? totalCount
|
|
191
|
+
report?.(` Total records: ${totalCount.toLocaleString()}`)
|
|
192
|
+
report?.(` OID range: ${startOID.toLocaleString()} .. ${endOID.toLocaleString()}`)
|
|
193
|
+
report?.(` Chunk size: ${chunkSize}, page size: ${pageSize}, concurrency: ${concurrency}`)
|
|
194
|
+
|
|
195
|
+
let fetched = 0
|
|
196
|
+
let skipped = 0
|
|
197
|
+
let totalRecords = 0
|
|
198
|
+
let totalErrors = 0
|
|
199
|
+
const failedCodes: string[] = []
|
|
200
|
+
|
|
201
|
+
for (let cursor = startOID; cursor <= endOID; cursor += chunkSize) {
|
|
202
|
+
const chunkEnd = Math.min(cursor + chunkSize - 1, endOID)
|
|
203
|
+
const chunkName = `oids_${cursor}-${chunkEnd}`
|
|
204
|
+
const chunkPath = join(chunkDir, `${chunkName}.ndjson`)
|
|
205
|
+
const manifestPath = join(chunkDir, `${chunkName}.manifest.json`)
|
|
206
|
+
|
|
207
|
+
// Idempotency: skip a chunk only if it's marked complete (the bash version's bug was
|
|
208
|
+
// marking complete on partial-failure runs; we now only set complete after a clean fetch).
|
|
209
|
+
if (existsSync(chunkPath)) {
|
|
210
|
+
const recorded = await readManifest<ChunkManifest>(manifestPath)
|
|
211
|
+
|
|
212
|
+
if (recorded?.complete) {
|
|
213
|
+
skipped++
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
report?.(` Fetching ${chunkName} ...`)
|
|
219
|
+
const t0 = Date.now()
|
|
220
|
+
const { recordCount, errors } = await fetchChunk(chunkPath, cursor, chunkEnd, pageSize, concurrency, report)
|
|
221
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1)
|
|
222
|
+
const bytes = statSync(chunkPath).size
|
|
223
|
+
const sha = await sha256File(chunkPath)
|
|
224
|
+
|
|
225
|
+
const manifest: ChunkManifest = {
|
|
226
|
+
source_url: FEATURE_SERVICE_URL,
|
|
227
|
+
oid_range: [cursor, chunkEnd],
|
|
228
|
+
downloaded_at: new Date().toISOString(),
|
|
229
|
+
filename: `${chunkName}.ndjson`,
|
|
230
|
+
sha256: sha,
|
|
231
|
+
bytes,
|
|
232
|
+
record_count: recordCount,
|
|
233
|
+
page_errors: errors,
|
|
234
|
+
complete: errors === 0,
|
|
235
|
+
}
|
|
236
|
+
await writeManifest(manifestPath, manifest)
|
|
237
|
+
|
|
238
|
+
const status = errors === 0 ? "✓" : `⚠ ${errors} page errors`
|
|
239
|
+
report?.(
|
|
240
|
+
` ${status} ${recordCount.toLocaleString()} records in ${elapsed}s (${(bytes / 1024 / 1024).toFixed(1)} MB)`
|
|
241
|
+
)
|
|
242
|
+
fetched++
|
|
243
|
+
totalRecords += recordCount
|
|
244
|
+
totalErrors += errors
|
|
245
|
+
|
|
246
|
+
if (errors > 0) {
|
|
247
|
+
failedCodes.push(chunkName)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
report?.(`=== featureserver summary ===`)
|
|
252
|
+
report?.(`chunks fetched: ${fetched} skipped: ${skipped}`)
|
|
253
|
+
report?.(`total records: ${totalRecords.toLocaleString()}`)
|
|
254
|
+
report?.(`page errors: ${totalErrors}`)
|
|
255
|
+
report?.(`output: ${chunkDir}`)
|
|
256
|
+
|
|
257
|
+
// `failed` counts page errors (the old exit-1 condition), `failedCodes` names the dirty chunks.
|
|
258
|
+
return { fetched, skipped, failed: totalErrors, failedCodes }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function bulkMode(options: FetchNADOptions, report?: (line: string) => void): Promise<FetchSummary> {
|
|
262
|
+
if (!options.nadURL) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`bulk mode requires --nad-url. The DOT page is Akamai-gated; ` +
|
|
265
|
+
`visit https://www.transportation.gov/gis/national-address-database in a browser, ` +
|
|
266
|
+
`accept the disclaimer, and re-run with the pre-signed S3 URL.`
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
const destDir = join(options.outRoot, SLUG)
|
|
270
|
+
mkdirSync(destDir, { recursive: true })
|
|
271
|
+
const filename = new URL(options.nadURL).pathname.split("/").pop() ?? "NAD.zip"
|
|
272
|
+
const destPath = join(destDir, filename)
|
|
273
|
+
|
|
274
|
+
report?.(`=== ${SLUG} / ${filename}`)
|
|
275
|
+
report?.(` URL: ${options.nadURL.slice(0, 100)}${options.nadURL.length > 100 ? "…" : ""}`)
|
|
276
|
+
|
|
277
|
+
const { bytes } = await downloadToFile({ url: options.nadURL, dest: destPath, timeoutMs: 3 * 3600 * 1000, report })
|
|
278
|
+
|
|
279
|
+
const sha = await sha256File(destPath)
|
|
280
|
+
await writeManifest(join(destDir, "MANIFEST.json"), {
|
|
281
|
+
source_url: options.nadURL,
|
|
282
|
+
downloaded_at: new Date().toISOString(),
|
|
283
|
+
filename,
|
|
284
|
+
sha256: sha,
|
|
285
|
+
bytes,
|
|
286
|
+
})
|
|
287
|
+
report?.(` ✓ ${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB sha256=${sha}`)
|
|
288
|
+
|
|
289
|
+
return { fetched: 1, skipped: 0, failed: 0, failedCodes: [] }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function fetchNAD(options: FetchNADOptions, report?: (line: string) => void): Promise<FetchSummary> {
|
|
293
|
+
const mode = options.mode ?? "featureserver"
|
|
294
|
+
|
|
295
|
+
if (mode === "featureserver") {
|
|
296
|
+
return featureserverMode(options, report)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (mode === "bulk") {
|
|
300
|
+
return bulkMode(options, report)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
throw new Error(`unknown mode "${String(mode)}" (expected featureserver|bulk)`)
|
|
304
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Re-fetch the NPPES (National Plan and Provider Enumeration System) full monthly data
|
|
7
|
+
* dissemination file. ~7M provider rows with venue+address data. Source for the `usgov-nppes`
|
|
8
|
+
* adapter. US Public Domain.
|
|
9
|
+
*
|
|
10
|
+
* The file is published monthly by CMS. This module discovers the current filename by scraping the
|
|
11
|
+
* NPI_Files.html index, then downloads the ZIP and extracts only the main registry CSV
|
|
12
|
+
* (npidata_pfile_*.csv). The smaller endpoint/othername/pl files stay zipped — we don't need them.
|
|
13
|
+
*
|
|
14
|
+
* Uses Node's built-in fetch (gzip/brotli) to parse the HTML index and download the ZIP, and
|
|
15
|
+
* streaming sha256 instead of sha256sum. The ZIP is unpacked with the `unzip` binary via
|
|
16
|
+
* `node:child_process` (no clean Node equivalent for member listing + selective extraction). NOTE:
|
|
17
|
+
* the old bash fetcher used `curl --continue-at -` to resume a partial download; native fetch has
|
|
18
|
+
* no resume, so a partial run re-downloads from the start.
|
|
19
|
+
*
|
|
20
|
+
* Invoke via `mailwoman corpus fetch nppes --out-root <path>`. Idempotent: if dest CSV exists and
|
|
21
|
+
* sha256 matches MANIFEST, skips download.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFile } from "node:child_process"
|
|
25
|
+
import { existsSync, mkdirSync, statSync } from "node:fs"
|
|
26
|
+
import { rm } from "node:fs/promises"
|
|
27
|
+
import { join } from "node:path"
|
|
28
|
+
import { promisify } from "node:util"
|
|
29
|
+
|
|
30
|
+
import { sha256File } from "@mailwoman/core/utils"
|
|
31
|
+
|
|
32
|
+
import type { BaseFetchOptions, FetchSummary } from "./download.ts"
|
|
33
|
+
import { downloadToFile, readManifest, writeManifest } from "./download.ts"
|
|
34
|
+
|
|
35
|
+
const execFileAsync = promisify(execFile)
|
|
36
|
+
|
|
37
|
+
const INDEX_URL = "https://download.cms.gov/nppes/NPI_Files.html"
|
|
38
|
+
const BASE_URL = "https://download.cms.gov/nppes"
|
|
39
|
+
const SLUG = "usgov-nppes"
|
|
40
|
+
|
|
41
|
+
export type FetchNPPESOptions = BaseFetchOptions
|
|
42
|
+
|
|
43
|
+
interface SourceManifest {
|
|
44
|
+
source_url: string
|
|
45
|
+
downloaded_at: string
|
|
46
|
+
filename: string
|
|
47
|
+
sha256: string
|
|
48
|
+
bytes: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Scrape the NPI_Files.html index for the latest full monthly ZIP. Full-replacement files match
|
|
53
|
+
* `NPPES_Data_Dissemination_<Month>_<Year>*.zip`; weekly files carry a `MMDDYY_MMDDYY` date range, which we exclude.
|
|
54
|
+
*/
|
|
55
|
+
async function discoverLatestZip(): Promise<string | undefined> {
|
|
56
|
+
const res = await fetch(INDEX_URL, {
|
|
57
|
+
headers: { "Accept-Encoding": "gzip, br" },
|
|
58
|
+
signal: AbortSignal.timeout(60_000),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} on ${INDEX_URL}`)
|
|
62
|
+
const html = await res.text()
|
|
63
|
+
|
|
64
|
+
for (const match of html.matchAll(/NPPES_Data_Dissemination_[A-Za-z]+_\d{4}[^"]*\.zip/g)) {
|
|
65
|
+
const name = match[0]
|
|
66
|
+
|
|
67
|
+
if (name && !/\d{6}_\d{6}/.test(name)) return name
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Extract the main registry CSV name (npidata_pfile_*.csv) from a ZIP's `unzip -l` listing. */
|
|
74
|
+
async function findNpidataCSV(zipPath: string): Promise<string | undefined> {
|
|
75
|
+
const listing = await execFileAsync("unzip", ["-l", zipPath])
|
|
76
|
+
|
|
77
|
+
for (const line of listing.stdout.split("\n")) {
|
|
78
|
+
const match = /npidata_pfile\S+\.csv/i.exec(line)
|
|
79
|
+
|
|
80
|
+
if (match?.[0]) return match[0]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function fetchNPPES(options: FetchNPPESOptions, report?: (line: string) => void): Promise<FetchSummary> {
|
|
87
|
+
const destDir = join(options.outRoot, SLUG)
|
|
88
|
+
mkdirSync(destDir, { recursive: true })
|
|
89
|
+
const manifestPath = join(destDir, "MANIFEST.json")
|
|
90
|
+
|
|
91
|
+
report?.(`=== ${SLUG}`)
|
|
92
|
+
report?.(` Discovering latest full-replacement ZIP from ${INDEX_URL} ...`)
|
|
93
|
+
|
|
94
|
+
const zipFilename = await discoverLatestZip()
|
|
95
|
+
|
|
96
|
+
if (!zipFilename) {
|
|
97
|
+
report?.(` ✗ Could not discover ZIP filename from ${INDEX_URL}`)
|
|
98
|
+
|
|
99
|
+
return { fetched: 0, skipped: 0, failed: 1, failedCodes: [SLUG] }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const zipURL = `${BASE_URL}/${zipFilename}`
|
|
103
|
+
const zipDest = join(destDir, zipFilename)
|
|
104
|
+
report?.(` Latest full file: ${zipFilename}`)
|
|
105
|
+
|
|
106
|
+
// ------------------------------------------------------------------
|
|
107
|
+
// Idempotency check: if the main CSV already exists and sha matches,
|
|
108
|
+
// skip re-download.
|
|
109
|
+
// ------------------------------------------------------------------
|
|
110
|
+
const recorded = await readManifest<Partial<SourceManifest>>(manifestPath)
|
|
111
|
+
|
|
112
|
+
if (recorded?.sha256 && recorded.filename) {
|
|
113
|
+
const recordedPath = join(destDir, recorded.filename)
|
|
114
|
+
|
|
115
|
+
if (existsSync(recordedPath) && (await sha256File(recordedPath)) === recorded.sha256) {
|
|
116
|
+
report?.(" ✓ Already current (sha256 matches MANIFEST) — skipping download.")
|
|
117
|
+
|
|
118
|
+
return { fetched: 0, skipped: 1, failed: 0, failedCodes: [] }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ------------------------------------------------------------------
|
|
123
|
+
// Download ZIP (large; 60-minute timeout)
|
|
124
|
+
// ------------------------------------------------------------------
|
|
125
|
+
report?.(` Downloading ${zipURL} ...`)
|
|
126
|
+
const { bytes: zipSize } = await downloadToFile({
|
|
127
|
+
url: zipURL,
|
|
128
|
+
dest: zipDest,
|
|
129
|
+
timeoutMs: 3_600_000,
|
|
130
|
+
headers: { "Accept-Encoding": "gzip, br" },
|
|
131
|
+
report,
|
|
132
|
+
})
|
|
133
|
+
report?.(` Downloaded: ${(zipSize / 1024 / 1024).toFixed(1)} MB`)
|
|
134
|
+
|
|
135
|
+
// ------------------------------------------------------------------
|
|
136
|
+
// Extract only the main registry CSV (npidata_pfile_*.csv)
|
|
137
|
+
// ------------------------------------------------------------------
|
|
138
|
+
report?.(" Extracting npidata_pfile CSV from ZIP ...")
|
|
139
|
+
const csvName = await findNpidataCSV(zipDest)
|
|
140
|
+
|
|
141
|
+
if (!csvName) {
|
|
142
|
+
report?.(" ✗ Could not find npidata_pfile CSV inside ZIP")
|
|
143
|
+
|
|
144
|
+
return { fetched: 0, skipped: 0, failed: 1, failedCodes: [SLUG] }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
report?.(` Extracting: ${csvName}`)
|
|
148
|
+
await execFileAsync("unzip", ["-o", "-j", zipDest, csvName, "-d", destDir])
|
|
149
|
+
|
|
150
|
+
const csvDest = join(destDir, csvName)
|
|
151
|
+
const csvSize = statSync(csvDest).size
|
|
152
|
+
const csvSha = await sha256File(csvDest)
|
|
153
|
+
report?.(` CSV size: ${(csvSize / 1024 / 1024).toFixed(1)} MB`)
|
|
154
|
+
|
|
155
|
+
// ------------------------------------------------------------------
|
|
156
|
+
// Remove the ZIP to reclaim ~1 GB (the CSV is what adapters consume)
|
|
157
|
+
// ------------------------------------------------------------------
|
|
158
|
+
await rm(zipDest, { force: true })
|
|
159
|
+
report?.(" Removed ZIP (CSV kept)")
|
|
160
|
+
|
|
161
|
+
// ------------------------------------------------------------------
|
|
162
|
+
// Write MANIFEST (records the extracted CSV, not the ZIP)
|
|
163
|
+
// ------------------------------------------------------------------
|
|
164
|
+
const manifest: SourceManifest = {
|
|
165
|
+
source_url: zipURL,
|
|
166
|
+
downloaded_at: new Date().toISOString(),
|
|
167
|
+
filename: csvName,
|
|
168
|
+
sha256: csvSha,
|
|
169
|
+
bytes: csvSize,
|
|
170
|
+
}
|
|
171
|
+
await writeManifest(manifestPath, manifest)
|
|
172
|
+
|
|
173
|
+
report?.(` ✓ ${(csvSize / 1024 / 1024).toFixed(1)} MB sha256=${csvSha}`)
|
|
174
|
+
report?.(` MANIFEST written to ${manifestPath}`)
|
|
175
|
+
|
|
176
|
+
return { fetched: 1, skipped: 0, failed: 0, failedCodes: [] }
|
|
177
|
+
}
|