@mailwoman/corpus 7.2.0 → 7.3.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/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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Decompose a US street name into Stage 3 components: street_prefix, street, street_suffix.
|
|
7
|
+
*
|
|
8
|
+
* Sources directionals and street types from the curated libpostal/en dictionaries
|
|
9
|
+
* (`core/data/libpostal/dictionaries/en/{directionals,street_types}.txt`). These are the same
|
|
10
|
+
* dictionaries the runtime classifiers (StreetPrefixClassifier, StreetSuffixClassifier) use, so
|
|
11
|
+
* corpus labels and runtime classifications agree on the vocabulary.
|
|
12
|
+
*
|
|
13
|
+
* Examples: "N Main St" → { prefix: "N", street: "Main", suffix: "St" } "Pennsylvania Avenue NW" →
|
|
14
|
+
* { prefix: null, street: "Pennsylvania", suffix: "Avenue NW" } "Salmon St" → { prefix: null,
|
|
15
|
+
* street: "Salmon", suffix: "St" } "SE Hawthorne Blvd" → { prefix: "SE", street: "Hawthorne",
|
|
16
|
+
* suffix: "Blvd" }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs"
|
|
20
|
+
import { resolve } from "node:path"
|
|
21
|
+
|
|
22
|
+
import { resourceDictionaryPath } from "@mailwoman/core/utils"
|
|
23
|
+
|
|
24
|
+
function loadDictionary(filename: string): Set<string> {
|
|
25
|
+
// Resolve via the @mailwoman/core data directory.
|
|
26
|
+
const candidates = [
|
|
27
|
+
resourceDictionaryPath("libpostal", "en", filename),
|
|
28
|
+
resourceDictionaryPath("libpostal", "en", filename),
|
|
29
|
+
resolve(process.cwd(), "core/data/libpostal/dictionaries/en", filename),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
for (const path of candidates) {
|
|
33
|
+
try {
|
|
34
|
+
const text = readFileSync(path, "utf8")
|
|
35
|
+
const set = new Set<string>()
|
|
36
|
+
|
|
37
|
+
for (const line of text.split("\n")) {
|
|
38
|
+
const trimmed = line.trim()
|
|
39
|
+
|
|
40
|
+
if (!trimmed || trimmed.startsWith("#")) continue
|
|
41
|
+
|
|
42
|
+
// libpostal format: canonical|abbr|abbr|... — index all forms
|
|
43
|
+
for (const form of trimmed.split("|")) {
|
|
44
|
+
const f = form.trim().toLowerCase()
|
|
45
|
+
|
|
46
|
+
if (f) {
|
|
47
|
+
set.add(f)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return set
|
|
53
|
+
} catch {
|
|
54
|
+
// try next candidate
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Could not load libpostal dictionary: ${filename}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DIRECTIONALS = loadDictionary("directionals.txt")
|
|
61
|
+
const STREET_TYPES = loadDictionary("street_types.txt")
|
|
62
|
+
|
|
63
|
+
export interface DecomposedStreet {
|
|
64
|
+
prefix: string | null
|
|
65
|
+
street: string
|
|
66
|
+
suffix: string | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Decompose a US street name into prefix/name/suffix components.
|
|
71
|
+
*
|
|
72
|
+
* Conservative — only emits prefix/suffix when there's a clear directional or street-type keyword. Returns the original
|
|
73
|
+
* as `street` if nothing matches.
|
|
74
|
+
*/
|
|
75
|
+
export function decomposeStreet(fullname: string): DecomposedStreet {
|
|
76
|
+
const trimmed = fullname.trim()
|
|
77
|
+
|
|
78
|
+
if (!trimmed) return { prefix: null, street: "", suffix: null }
|
|
79
|
+
|
|
80
|
+
const tokens = trimmed.split(/\s+/)
|
|
81
|
+
|
|
82
|
+
if (tokens.length === 1) return { prefix: null, street: trimmed, suffix: null }
|
|
83
|
+
|
|
84
|
+
const norm = (s: string) => s.toLowerCase().replace(/\.$/, "")
|
|
85
|
+
|
|
86
|
+
let prefix: string | null = null
|
|
87
|
+
let suffix: string | null = null
|
|
88
|
+
let startIdx = 0
|
|
89
|
+
let endIdx = tokens.length
|
|
90
|
+
|
|
91
|
+
// Leading directional prefix
|
|
92
|
+
if (DIRECTIONALS.has(norm(tokens[0]!)) && tokens.length >= 2) {
|
|
93
|
+
prefix = tokens[0]!
|
|
94
|
+
startIdx = 1
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Trailing post-directional combined with street type (e.g. "Pennsylvania Ave NW")
|
|
98
|
+
const last = norm(tokens[endIdx - 1]!)
|
|
99
|
+
const secondLast = endIdx >= 2 ? norm(tokens[endIdx - 2]!) : ""
|
|
100
|
+
|
|
101
|
+
if (DIRECTIONALS.has(last) && STREET_TYPES.has(secondLast)) {
|
|
102
|
+
suffix = tokens.slice(endIdx - 2, endIdx).join(" ")
|
|
103
|
+
endIdx -= 2
|
|
104
|
+
} else if (STREET_TYPES.has(last) && endIdx - startIdx >= 2) {
|
|
105
|
+
suffix = tokens[endIdx - 1]!
|
|
106
|
+
endIdx -= 1
|
|
107
|
+
} else if (DIRECTIONALS.has(last) && endIdx - startIdx >= 2) {
|
|
108
|
+
// Post-directional without type
|
|
109
|
+
suffix = tokens[endIdx - 1]!
|
|
110
|
+
endIdx -= 1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const street = tokens.slice(startIdx, endIdx).join(" ").trim()
|
|
114
|
+
|
|
115
|
+
if (!street) {
|
|
116
|
+
return { prefix: null, street: trimmed, suffix: null }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { prefix, street, suffix }
|
|
120
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `usgov-hrsa-fqhc`: HRSA "Health Center Service Delivery Site Locations" CSV consumer.
|
|
7
|
+
*
|
|
8
|
+
* Federally Qualified Health Centers (FQHCs) are HRSA-funded community health programs that
|
|
9
|
+
* self-report site addresses to the HRSA Data Warehouse. The published CSV (`data.hrsa.gov`)
|
|
10
|
+
* carries the site name, the postal-formatted street address, and the locality/region/postcode
|
|
11
|
+
* quad. Phase 1.6 §1.2 (#22) selects this source for its adversarial-value-per-row: every
|
|
12
|
+
* facility name is a human-typed venue string and the addresses pass through enough hands to
|
|
13
|
+
* accumulate the abbreviation drift + suite designator chaos that pure gazetteer data does not.
|
|
14
|
+
*
|
|
15
|
+
* The adapter consumes a CSV file the operator pre-downloads. The HRSA data is published as a
|
|
16
|
+
* single national CSV (~10K rows), small enough that the operator can re-fetch on every corpus
|
|
17
|
+
* rebuild without an intermediate SQLite step. Column names below match the HRSA Data Warehouse's
|
|
18
|
+
* "Health Center Service Delivery Site" public dataset. Operators substituting the
|
|
19
|
+
* closely-related "Site Address" or "Health Center" public extracts may need to remap columns;
|
|
20
|
+
* the README documents the expected set.
|
|
21
|
+
*
|
|
22
|
+
* Output: one row per CSV record, with `venue` component carrying the site name and the address
|
|
23
|
+
* quad on `(house_number, street, locality, region, postcode)`. Component order is critical:
|
|
24
|
+
* `venue` is inserted FIRST so alignment claims its surface span before `locality` searches for
|
|
25
|
+
* its own (the kryptonite case "Buffalo Health Clinic, …, Buffalo, NY" relies on `venue`
|
|
26
|
+
* consuming the first "Buffalo" so locality lands on the second).
|
|
27
|
+
*
|
|
28
|
+
* License: stamped `"Public Domain"` per the HRSA Data Warehouse's federal government distribution
|
|
29
|
+
* terms.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createReadStream } from "node:fs"
|
|
33
|
+
|
|
34
|
+
import { parse as csvParse } from "csv-parse"
|
|
35
|
+
|
|
36
|
+
import { stableSourceID } from "../../adapter.ts"
|
|
37
|
+
import { lookupStateAbbreviation } from "../../codex/us-fips-state.ts"
|
|
38
|
+
import { reconcileComponents } from "../../format.ts"
|
|
39
|
+
import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
|
|
40
|
+
|
|
41
|
+
export const USGOV_HRSA_FQHC_ADAPTER_ID = "usgov-hrsa-fqhc"
|
|
42
|
+
export const USGOV_HRSA_FQHC_DEFAULT_LICENSE = "Public Domain"
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Subset of HRSA "Health Center Service Delivery Site Locations" CSV columns consulted by the adapter. Column names
|
|
46
|
+
* match the canonical HRSA Data Warehouse export header. Operators substituting a closely-related extract should rename
|
|
47
|
+
* columns to match; the README has the mapping cheatsheet.
|
|
48
|
+
*/
|
|
49
|
+
interface HrsaSiteRow {
|
|
50
|
+
"Site Name": string
|
|
51
|
+
"Site Address": string
|
|
52
|
+
"Site City": string
|
|
53
|
+
"Site State Abbreviation": string
|
|
54
|
+
"Site Postal Code": string
|
|
55
|
+
/** Optional. Falls back to `stableSourceID` derived from components when missing. */
|
|
56
|
+
"Site ID"?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Split a "123 Main St Suite 4" surface form into `(house_number, street)`. The regex tolerates one trailing letter on
|
|
61
|
+
* the number (`"123A Main St"`) and a hyphenated form (`"40-12 Bell Blvd"`); anything else falls back to street-only.
|
|
62
|
+
*
|
|
63
|
+
* Suite / Apt / Unit designators stay on `street` for Phase 1 — Mailwoman's `unit` component exists but the
|
|
64
|
+
* address-formatter does not have a clean slot for it, and HRSA addresses do not separate the suite into its own
|
|
65
|
+
* column. Leaving the surface form intact in `street` preserves the adversarial training signal (the model learns that
|
|
66
|
+
* a trailing "Suite 4" is part of the road line in this distribution).
|
|
67
|
+
*/
|
|
68
|
+
const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
|
|
69
|
+
|
|
70
|
+
function splitAddress(address: string): { house_number?: string; street: string } | null {
|
|
71
|
+
const trimmed = address.trim()
|
|
72
|
+
|
|
73
|
+
if (!trimmed) return null
|
|
74
|
+
const m = HOUSE_NUMBER_PREFIX.exec(trimmed)
|
|
75
|
+
|
|
76
|
+
if (m) return { house_number: m[1], street: m[2]!.trim() }
|
|
77
|
+
|
|
78
|
+
return { street: trimmed }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compose the raw envelope-style address line. Format:
|
|
83
|
+
*
|
|
84
|
+
* "<Site Name>, <house> <street>, <city>, <state> <postcode>"
|
|
85
|
+
*
|
|
86
|
+
* The site name leads (US conventional addressee-then-address ordering) so a downstream model sees the
|
|
87
|
+
* venue-prefix-then-address shape that HRSA users actually type into geocoders.
|
|
88
|
+
*/
|
|
89
|
+
function composeRaw(
|
|
90
|
+
venue: string,
|
|
91
|
+
house: string | undefined,
|
|
92
|
+
street: string,
|
|
93
|
+
city: string,
|
|
94
|
+
state: string,
|
|
95
|
+
postcode: string
|
|
96
|
+
): string {
|
|
97
|
+
const streetPart = [house, street].filter(Boolean).join(" ").trim()
|
|
98
|
+
const cityPart = [city.trim(), [state, postcode].filter(Boolean).join(" ").trim()].filter(Boolean).join(", ")
|
|
99
|
+
|
|
100
|
+
return [venue.trim(), streetPart, cityPart].filter(Boolean).join(", ")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createUsgovHrsaFqhcAdapter(): CorpusAdapter {
|
|
104
|
+
return {
|
|
105
|
+
id: USGOV_HRSA_FQHC_ADAPTER_ID,
|
|
106
|
+
defaultLicense: USGOV_HRSA_FQHC_DEFAULT_LICENSE,
|
|
107
|
+
description:
|
|
108
|
+
"HRSA Federally Qualified Health Center site locations (public-domain). Adversarial source: venue + address co-occurrence, hand-entered.",
|
|
109
|
+
|
|
110
|
+
async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
|
|
111
|
+
if (opts.country && opts.country !== "US") {
|
|
112
|
+
throw new Error(`usgov-hrsa-fqhc adapter: only US supported, got country=${opts.country}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
|
|
116
|
+
const parser = stream.pipe(
|
|
117
|
+
csvParse({
|
|
118
|
+
columns: true,
|
|
119
|
+
skip_empty_lines: true,
|
|
120
|
+
relax_quotes: true,
|
|
121
|
+
relax_column_count: true,
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
let emitted = 0
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
for await (const record of parser as AsyncIterable<HrsaSiteRow>) {
|
|
129
|
+
if (opts.signal?.aborted) break
|
|
130
|
+
|
|
131
|
+
if (opts.limit !== undefined && emitted >= opts.limit) break
|
|
132
|
+
|
|
133
|
+
const venue = (record["Site Name"] ?? "").trim()
|
|
134
|
+
const split = splitAddress(record["Site Address"] ?? "")
|
|
135
|
+
const city = (record["Site City"] ?? "").trim()
|
|
136
|
+
const stateAbbr = (record["Site State Abbreviation"] ?? "").trim()
|
|
137
|
+
const postcode = (record["Site Postal Code"] ?? "").trim()
|
|
138
|
+
|
|
139
|
+
if (!venue || !split || !city || !postcode) continue
|
|
140
|
+
const state = lookupStateAbbreviation(stateAbbr)
|
|
141
|
+
|
|
142
|
+
if (!state) continue
|
|
143
|
+
|
|
144
|
+
// Insertion order matters here. `venue` first so alignment claims its span
|
|
145
|
+
// (which may contain a token like "Buffalo") before `locality` runs its
|
|
146
|
+
// search — the kryptonite case `Buffalo Health Clinic, Buffalo NY`
|
|
147
|
+
// otherwise mis-labels the venue's "Buffalo" as locality.
|
|
148
|
+
const components: CanonicalRow["components"] = {
|
|
149
|
+
venue,
|
|
150
|
+
...(split.house_number ? { house_number: split.house_number } : {}),
|
|
151
|
+
street: split.street,
|
|
152
|
+
locality: city,
|
|
153
|
+
region: state.abbreviation,
|
|
154
|
+
postcode,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const raw = composeRaw(venue, split.house_number, split.street, city, state.abbreviation, postcode)
|
|
158
|
+
|
|
159
|
+
if (!raw) continue
|
|
160
|
+
|
|
161
|
+
const aligned = reconcileComponents(components, raw)
|
|
162
|
+
|
|
163
|
+
if (Object.keys(aligned).length === 0) continue
|
|
164
|
+
|
|
165
|
+
const siteID = (record["Site ID"] ?? "").trim()
|
|
166
|
+
const sourceID = siteID
|
|
167
|
+
? `${USGOV_HRSA_FQHC_ADAPTER_ID}-${siteID}`
|
|
168
|
+
: stableSourceID(USGOV_HRSA_FQHC_ADAPTER_ID, aligned)
|
|
169
|
+
|
|
170
|
+
yield {
|
|
171
|
+
raw,
|
|
172
|
+
components: aligned,
|
|
173
|
+
country: "US",
|
|
174
|
+
locale: "en-US",
|
|
175
|
+
source: USGOV_HRSA_FQHC_ADAPTER_ID,
|
|
176
|
+
source_id: sourceID,
|
|
177
|
+
corpus_version: "",
|
|
178
|
+
license: USGOV_HRSA_FQHC_DEFAULT_LICENSE,
|
|
179
|
+
}
|
|
180
|
+
emitted++
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
stream.destroy()
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const usgovHrsaFqhcAdapter = createUsgovHrsaFqhcAdapter()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `usgov-imls-pls`: IMLS Public Libraries Survey outlet CSV consumer.
|
|
7
|
+
*
|
|
8
|
+
* The Institute of Museum and Library Services publishes an annual Public Libraries Survey with one
|
|
9
|
+
* row per library outlet (~17K rows). Each row carries the library name, street address, city,
|
|
10
|
+
* ZIP, county, and geocoordinates.
|
|
11
|
+
*
|
|
12
|
+
* The adapter consumes the outlet CSV the operator pre-downloads via `fetch-imls-pls.ts`. Column
|
|
13
|
+
* names match the IMLS PLS outlet file header.
|
|
14
|
+
*
|
|
15
|
+
* Output: one row per outlet with `venue` (library name), `(house_number, street, locality,
|
|
16
|
+
* subregion, postcode)`, and lat/lon preserved in `source_id` stability.
|
|
17
|
+
*
|
|
18
|
+
* License: stamped `"Public Domain"` per IMLS federal government distribution terms.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createReadStream } from "node:fs"
|
|
22
|
+
|
|
23
|
+
import { parse as csvParse } from "csv-parse"
|
|
24
|
+
|
|
25
|
+
import { stableSourceID } from "../../adapter.ts"
|
|
26
|
+
import { lookupStateAbbreviation } from "../../codex/us-fips-state.ts"
|
|
27
|
+
import { reconcileComponents } from "../../format.ts"
|
|
28
|
+
import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
|
|
29
|
+
|
|
30
|
+
export const USGOV_IMLS_PLS_ADAPTER_ID = "usgov-imls-pls"
|
|
31
|
+
export const USGOV_IMLS_PLS_DEFAULT_LICENSE = "Public Domain"
|
|
32
|
+
|
|
33
|
+
const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
|
|
34
|
+
|
|
35
|
+
interface ImlsOutletRow {
|
|
36
|
+
LIBNAME: string
|
|
37
|
+
ADDRESS: string
|
|
38
|
+
CITY: string
|
|
39
|
+
ZIP: string
|
|
40
|
+
STABR: string
|
|
41
|
+
CNTY: string
|
|
42
|
+
FSCSKEY: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function splitAddress(address: string): { house_number?: string; street: string } | null {
|
|
46
|
+
const trimmed = address.trim()
|
|
47
|
+
|
|
48
|
+
if (!trimmed) return null
|
|
49
|
+
const m = HOUSE_NUMBER_PREFIX.exec(trimmed)
|
|
50
|
+
|
|
51
|
+
if (m) return { house_number: m[1], street: m[2]!.trim() }
|
|
52
|
+
|
|
53
|
+
return { street: trimmed }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createUsgovImlsPlsAdapter(): CorpusAdapter {
|
|
57
|
+
return {
|
|
58
|
+
id: USGOV_IMLS_PLS_ADAPTER_ID,
|
|
59
|
+
defaultLicense: USGOV_IMLS_PLS_DEFAULT_LICENSE,
|
|
60
|
+
description: "IMLS Public Libraries Survey — ~17K library outlets with venue+address (public-domain).",
|
|
61
|
+
|
|
62
|
+
async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
|
|
63
|
+
if (opts.country && opts.country !== "US") {
|
|
64
|
+
throw new Error(`usgov-imls-pls adapter: only US supported, got country=${opts.country}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
|
|
68
|
+
const parser = stream.pipe(
|
|
69
|
+
csvParse({
|
|
70
|
+
columns: true,
|
|
71
|
+
skip_empty_lines: true,
|
|
72
|
+
relax_quotes: true,
|
|
73
|
+
relax_column_count: true,
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
let emitted = 0
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
for await (const record of parser as AsyncIterable<ImlsOutletRow>) {
|
|
81
|
+
if (opts.signal?.aborted) break
|
|
82
|
+
|
|
83
|
+
if (opts.limit !== undefined && emitted >= opts.limit) break
|
|
84
|
+
|
|
85
|
+
const libName = (record.LIBNAME ?? "").trim()
|
|
86
|
+
const address = (record.ADDRESS ?? "").trim()
|
|
87
|
+
const city = (record.CITY ?? "").trim()
|
|
88
|
+
const zip = (record.ZIP ?? "").trim()
|
|
89
|
+
const stateAbbr = (record.STABR ?? "").trim()
|
|
90
|
+
const county = (record.CNTY ?? "").trim()
|
|
91
|
+
|
|
92
|
+
if (!libName || !city || !zip) continue
|
|
93
|
+
|
|
94
|
+
const state = lookupStateAbbreviation(stateAbbr)
|
|
95
|
+
|
|
96
|
+
if (!state) continue
|
|
97
|
+
|
|
98
|
+
const split = splitAddress(address)
|
|
99
|
+
|
|
100
|
+
if (!split) continue
|
|
101
|
+
|
|
102
|
+
const components: CanonicalRow["components"] = {
|
|
103
|
+
venue: libName,
|
|
104
|
+
...(split.house_number ? { house_number: split.house_number } : {}),
|
|
105
|
+
street: split.street,
|
|
106
|
+
locality: city,
|
|
107
|
+
region: state.abbreviation,
|
|
108
|
+
postcode: zip,
|
|
109
|
+
// #552: no subregion — US postal addresses don't surface the county, so emitting
|
|
110
|
+
// subregion creates a phantom component with no raw-span to align to, quarantining
|
|
111
|
+
// ~21% of rows. The county is still available in the source CSV; it just isn't
|
|
112
|
+
// a postal-surface component here.
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const streetPart = [split.house_number, split.street].filter(Boolean).join(" ").trim()
|
|
116
|
+
const raw = [
|
|
117
|
+
libName,
|
|
118
|
+
streetPart,
|
|
119
|
+
[city, [stateAbbr, zip].filter(Boolean).join(" ")].filter(Boolean).join(", "),
|
|
120
|
+
]
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.join(", ")
|
|
123
|
+
|
|
124
|
+
const aligned = reconcileComponents(components, raw)
|
|
125
|
+
|
|
126
|
+
if (Object.keys(aligned).length <= 2) continue
|
|
127
|
+
|
|
128
|
+
const fscsKey = (record.FSCSKEY ?? "").trim()
|
|
129
|
+
const sourceID = fscsKey
|
|
130
|
+
? `${USGOV_IMLS_PLS_ADAPTER_ID}-${fscsKey}`
|
|
131
|
+
: stableSourceID(USGOV_IMLS_PLS_ADAPTER_ID, aligned)
|
|
132
|
+
|
|
133
|
+
yield {
|
|
134
|
+
raw,
|
|
135
|
+
components: aligned,
|
|
136
|
+
country: "US",
|
|
137
|
+
locale: "en-US",
|
|
138
|
+
source: USGOV_IMLS_PLS_ADAPTER_ID,
|
|
139
|
+
source_id: sourceID,
|
|
140
|
+
corpus_version: "",
|
|
141
|
+
license: USGOV_IMLS_PLS_DEFAULT_LICENSE,
|
|
142
|
+
}
|
|
143
|
+
emitted++
|
|
144
|
+
}
|
|
145
|
+
} finally {
|
|
146
|
+
stream.destroy()
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const usgovImlsPlsAdapter = createUsgovImlsPlsAdapter()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `usgov-irs-bmf`: IRS Exempt Organizations Business Master File (EO BMF) CSV consumer.
|
|
7
|
+
*
|
|
8
|
+
* The EO BMF is the IRS's authoritative registry of US tax-exempt organizations (charities,
|
|
9
|
+
* churches, foundations, ...), published as per-region CSVs at
|
|
10
|
+
* `https://www.irs.gov/charities-non-profits/exempt-organizations-business-master-file-extract-eo-bmf`
|
|
11
|
+
* (`eo1.csv`..`eo4.csv`, `eo_pr.csv`, `eo_xx.csv`). Each row carries an organization NAME plus
|
|
12
|
+
* its mailing address. It complements `usgov-nppes` with a DIFFERENT venue population
|
|
13
|
+
* (non-profits vs healthcare providers) and, notably, a high share of PO-box addresses — useful
|
|
14
|
+
* `po_box`-tag signal (a tag with historically low recall).
|
|
15
|
+
*
|
|
16
|
+
* Output: one row per record with a usable city + postcode. NAME → `venue`; the street line becomes
|
|
17
|
+
* `po_box` when it's a PO-box, else `house_number` + `street`; CITY/STATE/ZIP fill the locality
|
|
18
|
+
* line. STATE is already a USPS abbreviation in the source. License: `"Public Domain"` (US
|
|
19
|
+
* federal).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createReadStream } from "node:fs"
|
|
23
|
+
|
|
24
|
+
import { parse as csvParse } from "csv-parse"
|
|
25
|
+
|
|
26
|
+
import { stableSourceID } from "../../adapter.ts"
|
|
27
|
+
import { reconcileComponents } from "../../format.ts"
|
|
28
|
+
import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
|
|
29
|
+
|
|
30
|
+
export const USGOV_IRS_BMF_ADAPTER_ID = "usgov-irs-bmf"
|
|
31
|
+
export const USGOV_IRS_BMF_DEFAULT_LICENSE = "Public Domain"
|
|
32
|
+
|
|
33
|
+
const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
|
|
34
|
+
// PO box in its many written forms: "PO BOX 12", "P.O. BOX 12", "P O BOX 12", "POB 12", "BOX 12".
|
|
35
|
+
const PO_BOX = /^\s*(?:P\.?\s?O\.?\s*BOX|POB|BOX)\s+\w/i
|
|
36
|
+
|
|
37
|
+
interface IrsBmfRow {
|
|
38
|
+
EIN: string
|
|
39
|
+
NAME: string
|
|
40
|
+
STREET: string
|
|
41
|
+
CITY: string
|
|
42
|
+
STATE: string
|
|
43
|
+
ZIP: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Classify the street line into a `po_box` or a `{house_number?, street}` split. */
|
|
47
|
+
function splitStreetLine(street: string): { po_box: string } | { house_number?: string; street: string } | null {
|
|
48
|
+
const trimmed = street.trim()
|
|
49
|
+
|
|
50
|
+
if (!trimmed) return null
|
|
51
|
+
|
|
52
|
+
if (PO_BOX.test(trimmed)) return { po_box: trimmed }
|
|
53
|
+
const m = HOUSE_NUMBER_PREFIX.exec(trimmed)
|
|
54
|
+
|
|
55
|
+
if (m) return { house_number: m[1], street: m[2]!.trim() }
|
|
56
|
+
|
|
57
|
+
return { street: trimmed }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function composeRaw(
|
|
61
|
+
venue: string | undefined,
|
|
62
|
+
streetPart: string,
|
|
63
|
+
city: string,
|
|
64
|
+
state: string,
|
|
65
|
+
postcode: string
|
|
66
|
+
): string {
|
|
67
|
+
const cityPart = [city.trim(), [state, postcode].filter(Boolean).join(" ").trim()].filter(Boolean).join(", ")
|
|
68
|
+
|
|
69
|
+
return [venue, streetPart, cityPart].filter(Boolean).join(", ")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createUsgovIrsBmfAdapter(): CorpusAdapter {
|
|
73
|
+
return {
|
|
74
|
+
id: USGOV_IRS_BMF_ADAPTER_ID,
|
|
75
|
+
defaultLicense: USGOV_IRS_BMF_DEFAULT_LICENSE,
|
|
76
|
+
description:
|
|
77
|
+
"IRS Exempt Organizations Business Master File — US non-profit venue+address (public-domain), with strong PO-box coverage.",
|
|
78
|
+
|
|
79
|
+
async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
|
|
80
|
+
if (opts.country && opts.country !== "US") {
|
|
81
|
+
throw new Error(`usgov-irs-bmf adapter: only US supported, got country=${opts.country}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
|
|
85
|
+
const parser = stream.pipe(
|
|
86
|
+
csvParse({ columns: true, skip_empty_lines: true, relax_quotes: true, relax_column_count: true, trim: true })
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
let emitted = 0
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
for await (const record of parser as AsyncIterable<IrsBmfRow>) {
|
|
93
|
+
if (opts.signal?.aborted) break
|
|
94
|
+
|
|
95
|
+
if (opts.limit !== undefined && emitted >= opts.limit) break
|
|
96
|
+
|
|
97
|
+
const ein = (record.EIN ?? "").trim()
|
|
98
|
+
const venue = (record.NAME ?? "").trim() || undefined
|
|
99
|
+
const street = (record.STREET ?? "").trim()
|
|
100
|
+
const city = (record.CITY ?? "").trim()
|
|
101
|
+
const state = (record.STATE ?? "").trim()
|
|
102
|
+
const zipRaw = (record.ZIP ?? "").trim()
|
|
103
|
+
|
|
104
|
+
if (!city || !zipRaw) continue
|
|
105
|
+
const postcode = zipRaw.split("-")[0]!.trim() // 5-digit; drop the optional +4
|
|
106
|
+
|
|
107
|
+
const split = splitStreetLine(street)
|
|
108
|
+
|
|
109
|
+
if (!split) continue
|
|
110
|
+
|
|
111
|
+
const streetPart =
|
|
112
|
+
"po_box" in split ? split.po_box : [split.house_number, split.street].filter(Boolean).join(" ")
|
|
113
|
+
|
|
114
|
+
const components: CanonicalRow["components"] = {
|
|
115
|
+
...(venue ? { venue } : {}),
|
|
116
|
+
...("po_box" in split
|
|
117
|
+
? { po_box: split.po_box }
|
|
118
|
+
: { ...(split.house_number ? { house_number: split.house_number } : {}), street: split.street }),
|
|
119
|
+
locality: city,
|
|
120
|
+
...(state ? { region: state } : {}),
|
|
121
|
+
postcode,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const raw = composeRaw(venue, streetPart, city, state, postcode)
|
|
125
|
+
|
|
126
|
+
if (!raw) continue
|
|
127
|
+
|
|
128
|
+
const aligned = reconcileComponents(components, raw)
|
|
129
|
+
|
|
130
|
+
if (Object.keys(aligned).length <= 2) continue
|
|
131
|
+
|
|
132
|
+
const sourceID = ein
|
|
133
|
+
? `${USGOV_IRS_BMF_ADAPTER_ID}-${ein}`
|
|
134
|
+
: stableSourceID(USGOV_IRS_BMF_ADAPTER_ID, aligned)
|
|
135
|
+
|
|
136
|
+
yield {
|
|
137
|
+
raw,
|
|
138
|
+
components: aligned,
|
|
139
|
+
country: "US",
|
|
140
|
+
locale: "en-US",
|
|
141
|
+
source: USGOV_IRS_BMF_ADAPTER_ID,
|
|
142
|
+
source_id: sourceID,
|
|
143
|
+
corpus_version: "",
|
|
144
|
+
license: USGOV_IRS_BMF_DEFAULT_LICENSE,
|
|
145
|
+
}
|
|
146
|
+
emitted++
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
stream.destroy()
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const usgovIrsBmfAdapter = createUsgovIrsBmfAdapter()
|