@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.
Files changed (103) hide show
  1. package/package.json +59 -27
  2. package/src/adapter.ts +153 -0
  3. package/src/adapters/ban/adapter.ts +189 -0
  4. package/src/adapters/ban/street-decompose.ts +98 -0
  5. package/src/adapters/fcc-bdc/adapter.ts +188 -0
  6. package/src/adapters/geonames/adapter.ts +185 -0
  7. package/src/adapters/geonames-postal/adapter.ts +111 -0
  8. package/src/adapters/gnaf/adapter.ts +139 -0
  9. package/src/adapters/gnaf/assemble.ts +200 -0
  10. package/src/adapters/index.ts +135 -0
  11. package/src/adapters/openaddresses/adapter.ts +237 -0
  12. package/src/adapters/overture/adapter.ts +149 -0
  13. package/src/adapters/state-hi-schools/adapter.ts +171 -0
  14. package/src/adapters/state-ia-contractors/adapter.ts +148 -0
  15. package/src/adapters/state-ny-notaries/adapter.ts +169 -0
  16. package/src/adapters/state-tx-notaries/adapter.ts +160 -0
  17. package/src/adapters/synth-po-box/adapter.ts +197 -0
  18. package/src/adapters/tiger/adapter.ts +232 -0
  19. package/src/adapters/tiger/street-decompose.ts +120 -0
  20. package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
  21. package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
  22. package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
  23. package/src/adapters/usgov-nad/adapter.ts +338 -0
  24. package/src/adapters/usgov-nppes/adapter.ts +169 -0
  25. package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
  26. package/src/adapters/wof-admin-jp/adapter.ts +176 -0
  27. package/src/adapters/wof-admin-json/adapter.ts +280 -0
  28. package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
  29. package/src/align.ts +317 -0
  30. package/src/build.ts +348 -0
  31. package/src/codex/us-fips-state.ts +109 -0
  32. package/src/format.ts +14 -0
  33. package/src/golden.ts +147 -0
  34. package/src/index.ts +28 -0
  35. package/src/license.ts +48 -0
  36. package/src/parquet-wrapper/index.ts +9 -0
  37. package/src/parquet-wrapper/reader.ts +67 -0
  38. package/src/parquet-wrapper/schema.ts +80 -0
  39. package/src/parquet-wrapper/writer.ts +102 -0
  40. package/src/parquet.ts +360 -0
  41. package/src/runner.ts +283 -0
  42. package/src/shard-recipes/anchor-absorption.ts +86 -0
  43. package/src/shard-recipes/boundary-stress.ts +87 -0
  44. package/src/shard-recipes/country-balanced.ts +471 -0
  45. package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
  46. package/src/shard-recipes/fr-admin-split.ts +212 -0
  47. package/src/shard-recipes/fr-bare-street.ts +85 -0
  48. package/src/shard-recipes/fr-fragment.ts +304 -0
  49. package/src/shard-recipes/fr-order.ts +283 -0
  50. package/src/shard-recipes/german.ts +206 -0
  51. package/src/shard-recipes/house-venue.ts +78 -0
  52. package/src/shard-recipes/index.ts +76 -0
  53. package/src/shard-recipes/intersection.ts +617 -0
  54. package/src/shard-recipes/locale.ts +415 -0
  55. package/src/shard-recipes/nl-postcode.ts +105 -0
  56. package/src/shard-recipes/no-fragment.ts +206 -0
  57. package/src/shard-recipes/no-street-led.ts +130 -0
  58. package/src/shard-recipes/no-street.ts +78 -0
  59. package/src/shard-recipes/po-box-cedex.ts +853 -0
  60. package/src/shard-recipes/po-box.ts +111 -0
  61. package/src/shard-recipes/scaffold.ts +187 -0
  62. package/src/shard-recipes/si-bare-village.ts +88 -0
  63. package/src/shard-recipes/street-affix.ts +549 -0
  64. package/src/shard-recipes/street-bare.ts +90 -0
  65. package/src/shard-recipes/street.ts +74 -0
  66. package/src/shard-recipes/unit.ts +315 -0
  67. package/src/split.ts +251 -0
  68. package/src/synthesize-anchor-absorption.ts +263 -0
  69. package/src/synthesize-boundary-stress.ts +504 -0
  70. package/src/synthesize-german.ts +227 -0
  71. package/src/synthesize-house-venue.ts +192 -0
  72. package/src/synthesize-intersection.ts +205 -0
  73. package/src/synthesize-no-street.ts +341 -0
  74. package/src/synthesize-po-box.ts +307 -0
  75. package/src/synthesize-street.ts +264 -0
  76. package/src/synthesize.ts +872 -0
  77. package/src/tokenize.ts +68 -0
  78. package/src/tools/align-shard.ts +67 -0
  79. package/src/tools/audit.ts +352 -0
  80. package/src/tools/corpus-stats.ts +183 -0
  81. package/src/tools/fetch/ban.ts +288 -0
  82. package/src/tools/fetch/download.ts +119 -0
  83. package/src/tools/fetch/hrsa.ts +65 -0
  84. package/src/tools/fetch/imls-pls.ts +162 -0
  85. package/src/tools/fetch/index.ts +121 -0
  86. package/src/tools/fetch/nad.ts +304 -0
  87. package/src/tools/fetch/nppes.ts +177 -0
  88. package/src/tools/fetch/openaddresses.ts +380 -0
  89. package/src/tools/fetch/state-hi-schools.ts +217 -0
  90. package/src/tools/fetch/state-sources.ts +142 -0
  91. package/src/tools/fetch/tiger-full.ts +318 -0
  92. package/src/tools/golden-expand.ts +524 -0
  93. package/src/tools/golden-promote.ts +304 -0
  94. package/src/tools/index.ts +24 -0
  95. package/src/tools/ingest-csv.ts +420 -0
  96. package/src/tools/jsonl-to-parquet.ts +200 -0
  97. package/src/tools/lint-shard-vocab.ts +357 -0
  98. package/src/tools/lint-shard.ts +502 -0
  99. package/src/tools/overlay-manifest.ts +141 -0
  100. package/src/tools/shard-kryptonite.ts +131 -0
  101. package/src/tools/shard-translit.ts +244 -0
  102. package/src/types.ts +213 -0
  103. package/src/wof-json.ts +236 -0
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `overture`: Overture Maps Addresses adapter (epic #470 — the gated corpus adapter, realized
7
+ * 2026-06-20). Overture's global Addresses theme is the single-schema, well-normalized address
8
+ * dataset that fixes OpenAddresses' per-country patchiness (OA dropped Spain; OA-DE omits the
9
+ * Bundesland) — it even re-hosts the OA Spain data the standalone OA bucket no longer serves.
10
+ *
11
+ * This adapter consumes a per-country LINE-DELIMITED JSON dump of the corpus-relevant fields (`{
12
+ * street, number, unit, postcode, locality }`), produced by `scripts/ingest-overture-addresses.ts
13
+ * --corpus-jsonl` (which does the DuckDB / S3 heavy lifting and flattens `address_levels` → the
14
+ * municipality locality). The split keeps `@mailwoman/corpus` — a RUNTIME dep of the `mailwoman`
15
+ * CLI — free of the heavy native `@duckdb/node-api`; the adapter just streams JSONL line-by-line,
16
+ * exactly like `openaddresses`.
17
+ *
18
+ * The `street` surface carries the locale's street keyword verbatim (`"CALLE JULAN"`, `"VIA
19
+ * ROMA"`). We map it to `street` whole and let the downstream affix-relabel split `street_prefix`
20
+ * — the same path every other source rides. This shard exists because the model was
21
+ * en-us/fr-trained and never saw non-en/fr street formats (the 2026-06-19 EU parse-blocker
22
+ * measured loc-correct ES 21% / IT 59% / NL 64% vs FR/US ~98%).
23
+ *
24
+ * `--country` is REQUIRED (the JSONL is per-country and the rows omit a country field), matching
25
+ * `openaddresses`. License is Overture's CDLA-Permissive-2.0 (attribution; not share-alike).
26
+ *
27
+ * | Field | ComponentTag | | --------- | ---------------------------------------------- | |
28
+ * `street` | `street` (keyword incl.; affix-relabel splits prefix) | | `number` | `house_number`
29
+ * (skipped when "S-N"/"S/N" = sin número) | | `unit` | `unit` (if non-empty) | | `postcode`|
30
+ * `postcode` | | `locality`| `locality` (Overture address_levels municipality, or postal_city) |
31
+ */
32
+
33
+ import { TextSpliterator } from "spliterator"
34
+
35
+ import { stableSourceID } from "../../adapter.ts"
36
+ import { formatAddress, reconcileComponents } from "../../format.ts"
37
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
38
+
39
+ export const OVERTURE_ADAPTER_ID = "overture"
40
+ export const OVERTURE_DEFAULT_LICENSE = "CDLA-Permissive-2.0"
41
+
42
+ /** The flattened per-row shape emitted by `ingest-overture-addresses.ts --corpus-jsonl`. */
43
+ interface OvertureCorpusRow {
44
+ street?: string
45
+ number?: string
46
+ unit?: string
47
+ postcode?: string
48
+ locality?: string
49
+ }
50
+
51
+ function parseLine(line: string): OvertureCorpusRow | null {
52
+ const t = line.trim()
53
+
54
+ if (!t || t.startsWith("#")) return null
55
+
56
+ try {
57
+ const o = JSON.parse(t)
58
+
59
+ return o && typeof o === "object" ? (o as OvertureCorpusRow) : null
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ export function createOvertureAdapter(): CorpusAdapter {
66
+ return {
67
+ id: OVERTURE_ADAPTER_ID,
68
+ defaultLicense: OVERTURE_DEFAULT_LICENSE,
69
+ description: "Overture Maps Addresses (global): per-country JSONL of street/number/postcode/locality.",
70
+
71
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
72
+ if (!opts.country) {
73
+ throw new Error(
74
+ "overture adapter: --country is required (the Overture JSONL is per-country and rows omit a country field)"
75
+ )
76
+ }
77
+ const country = opts.country
78
+
79
+ // TextSpliterator streams string lines (parseLine keeps tolerating blank/`#`/malformed
80
+ // lines by returning null); the path string lets the lib own + dispose the file handle,
81
+ // including on an early `break`.
82
+ const lines = TextSpliterator.fromAsync(opts.inputPath)
83
+
84
+ let emitted = 0
85
+
86
+ for await (const line of lines) {
87
+ if (opts.signal?.aborted) break
88
+
89
+ if (opts.limit !== undefined && emitted >= opts.limit) break
90
+
91
+ const r = parseLine(line)
92
+
93
+ if (!r) continue
94
+
95
+ const street = r.street?.trim() ?? ""
96
+ const number = r.number?.trim() ?? ""
97
+ const unit = r.unit?.trim() ?? ""
98
+ const postcode = r.postcode?.trim() ?? ""
99
+ const locality = r.locality?.trim() ?? ""
100
+
101
+ // Only useful with a street + (postcode OR locality); point-only rows quarantine anyway.
102
+ if (!street) continue
103
+
104
+ if (!postcode && !locality) continue
105
+
106
+ const components: CanonicalRow["components"] = {}
107
+
108
+ // Overture "S-N" / "S/N" = sin número; only keep a real numeric house number.
109
+ if (/^\d/.test(number)) {
110
+ components.house_number = number
111
+ }
112
+ components.street = street
113
+
114
+ if (unit) {
115
+ components.unit = unit
116
+ }
117
+
118
+ if (postcode) {
119
+ components.postcode = postcode
120
+ }
121
+
122
+ if (locality) {
123
+ components.locality = locality
124
+ }
125
+
126
+ const raw = formatAddress(components, country, { separator: ", " })
127
+
128
+ if (!raw) continue
129
+
130
+ const aligned = reconcileComponents(components, raw)
131
+
132
+ if (Object.keys(aligned).length === 0) continue
133
+
134
+ yield {
135
+ raw,
136
+ components: aligned,
137
+ country,
138
+ source: OVERTURE_ADAPTER_ID,
139
+ source_id: stableSourceID(OVERTURE_ADAPTER_ID, aligned),
140
+ corpus_version: "",
141
+ license: OVERTURE_DEFAULT_LICENSE,
142
+ }
143
+ emitted++
144
+ }
145
+ },
146
+ }
147
+ }
148
+
149
+ export const overtureAdapter = createOvertureAdapter()
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `state-hi-schools`: Hawaii DOE public + charter schools CSV consumer.
7
+ *
8
+ * The Hawaii State Department of Education publishes a directory of all HIDOE schools and public
9
+ * charter schools (PCS) as an XLSX workbook (`SchoolList.xlsx`) with two sheets: `HIDOE` (~258
10
+ * rows) and `PCS` (~38 rows). Total ~296 rows statewide. Each row carries a school name,
11
+ * single-line street address, city, ZIP, a numeric `code`, and HI-specific administrative columns
12
+ * (complex, complex_area, district, island, charter).
13
+ *
14
+ * The adapter consumes a flat CSV the operator pre-builds via `fetch-state-hi-schools.ts`, which
15
+ * concatenates both sheets under one shared header. Column names match the workbook header
16
+ * verbatim (lower-snake-case: `code`, `name`, `address`, `city`, `zip`, ...).
17
+ *
18
+ * Address parsing notes: Hawaii's residential numbering is hyphenated on Oahu (`47-470 Hui Aeko
19
+ * Place`), Kauai (`2-4035 Kaumualii Hwy`), and elsewhere. The shared HOUSE_NUMBER_PREFIX regex
20
+ * covers this via its optional `(?:-\d+)?` group.
21
+ *
22
+ * The `island` and `district` columns are HIDOE administrative labels (Honolulu, Central, Leeward,
23
+ * Windward, Hilo, Hawaii, Maui, Kauai) — they are NOT US counties and intentionally are not
24
+ * surfaced as `subregion`.
25
+ *
26
+ * Output: one row per school with `venue` (school name), `(house_number?, street, locality,
27
+ * region=HI, postcode)`, and a stable `source_id` derived from the school `code`.
28
+ *
29
+ * License: stamped `"Public Domain"` per Hawaii state government open-data 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 STATE_HI_SCHOOLS_ADAPTER_ID = "state-hi-schools"
42
+ export const STATE_HI_SCHOOLS_DEFAULT_LICENSE = "Public Domain"
43
+
44
+ const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
45
+ const HI_STATE_ABBR = "HI"
46
+
47
+ interface HiSchoolRow {
48
+ code: string
49
+ name: string
50
+ address: string
51
+ city: string
52
+ zip: string
53
+ }
54
+
55
+ function splitAddress(address: string): { house_number?: string; street: string } | null {
56
+ const trimmed = address.trim()
57
+
58
+ if (!trimmed) return null
59
+ const m = HOUSE_NUMBER_PREFIX.exec(trimmed)
60
+
61
+ if (m) return { house_number: m[1], street: m[2]!.trim() }
62
+
63
+ return { street: trimmed }
64
+ }
65
+
66
+ function normalizeZip(raw: string): string {
67
+ const trimmed = raw.trim()
68
+
69
+ if (!trimmed) return ""
70
+
71
+ // XLSX → CSV conversion may emit numeric ZIPs without leading zeros. HI ZIPs all begin
72
+ // with 96, so a 4-digit value indicates a leading-zero stripped during numeric coercion
73
+ // (defensive — has not been observed in the published file as of 2026-05).
74
+ if (/^\d{4}$/.test(trimmed)) return `0${trimmed}`
75
+
76
+ return trimmed
77
+ }
78
+
79
+ export function createStateHiSchoolsAdapter(): CorpusAdapter {
80
+ return {
81
+ id: STATE_HI_SCHOOLS_ADAPTER_ID,
82
+ defaultLicense: STATE_HI_SCHOOLS_DEFAULT_LICENSE,
83
+ description: "Hawaii DOE School Directory — ~300 K-12 public + charter schools with venue+address (public-domain).",
84
+
85
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
86
+ if (opts.country && opts.country !== "US") {
87
+ throw new Error(`state-hi-schools adapter: only US supported, got country=${opts.country}`)
88
+ }
89
+
90
+ const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
91
+ const parser = stream.pipe(
92
+ csvParse({
93
+ columns: true,
94
+ skip_empty_lines: true,
95
+ relax_quotes: true,
96
+ relax_column_count: true,
97
+ })
98
+ )
99
+
100
+ const state = lookupStateAbbreviation(HI_STATE_ABBR)
101
+
102
+ if (!state) {
103
+ throw new Error(`state-hi-schools adapter: HI not found in state codex (corpus bug)`)
104
+ }
105
+
106
+ let emitted = 0
107
+
108
+ try {
109
+ for await (const record of parser as AsyncIterable<HiSchoolRow>) {
110
+ if (opts.signal?.aborted) break
111
+
112
+ if (opts.limit !== undefined && emitted >= opts.limit) break
113
+
114
+ const name = (record.name ?? "").trim()
115
+ const address = (record.address ?? "").trim()
116
+ const city = (record.city ?? "").trim()
117
+ const zip = normalizeZip(record.zip ?? "")
118
+
119
+ if (!name || !address || !city || !zip) continue
120
+
121
+ const split = splitAddress(address)
122
+
123
+ if (!split) continue
124
+
125
+ const components: CanonicalRow["components"] = {
126
+ venue: name,
127
+ ...(split.house_number ? { house_number: split.house_number } : {}),
128
+ street: split.street,
129
+ locality: city,
130
+ region: state.abbreviation,
131
+ postcode: zip,
132
+ }
133
+
134
+ const streetPart = [split.house_number, split.street].filter(Boolean).join(" ").trim()
135
+ const raw = [
136
+ name,
137
+ streetPart,
138
+ [city, [HI_STATE_ABBR, zip].filter(Boolean).join(" ")].filter(Boolean).join(", "),
139
+ ]
140
+ .filter(Boolean)
141
+ .join(", ")
142
+
143
+ const aligned = reconcileComponents(components, raw)
144
+
145
+ if (Object.keys(aligned).length <= 2) continue
146
+
147
+ const code = (record.code ?? "").toString().trim()
148
+ const sourceID = code
149
+ ? `${STATE_HI_SCHOOLS_ADAPTER_ID}-${code}`
150
+ : stableSourceID(STATE_HI_SCHOOLS_ADAPTER_ID, aligned)
151
+
152
+ yield {
153
+ raw,
154
+ components: aligned,
155
+ country: "US",
156
+ locale: "en-US",
157
+ source: STATE_HI_SCHOOLS_ADAPTER_ID,
158
+ source_id: sourceID,
159
+ corpus_version: "",
160
+ license: STATE_HI_SCHOOLS_DEFAULT_LICENSE,
161
+ }
162
+ emitted++
163
+ }
164
+ } finally {
165
+ stream.destroy()
166
+ }
167
+ },
168
+ }
169
+ }
170
+
171
+ export const stateHiSchoolsAdapter = createStateHiSchoolsAdapter()
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `state-ia-contractors`: Iowa Active Construction Contractor Registrations CSV consumer.
7
+ *
8
+ * Iowa Workforce Development publishes a public registry of active construction contractors. Each
9
+ * row carries a business name, street address, city/state/zip, and contact info.
10
+ *
11
+ * The adapter consumes the CSV the operator pre-downloads via `fetch-state-sources.ts`.
12
+ *
13
+ * Output: one row per contractor with `venue` (business name) and address quad `(house_number,
14
+ * street, locality, region, postcode)`.
15
+ *
16
+ * License: stamped `"Public Domain"` per Iowa state government open-data terms.
17
+ */
18
+
19
+ import { createReadStream } from "node:fs"
20
+
21
+ import { parse as csvParse } from "csv-parse"
22
+
23
+ import { stableSourceID } from "../../adapter.ts"
24
+ import { lookupStateAbbreviation } from "../../codex/us-fips-state.ts"
25
+ import { reconcileComponents } from "../../format.ts"
26
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
27
+
28
+ export const STATE_IA_CONTRACTORS_ADAPTER_ID = "state-ia-contractors"
29
+ export const STATE_IA_CONTRACTORS_DEFAULT_LICENSE = "Public Domain"
30
+
31
+ const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
32
+
33
+ interface IaContractorRow {
34
+ "Registration #": string
35
+ "Business Name": string
36
+ "Address 1": string
37
+ "Address 2": string
38
+ City: string
39
+ State: string
40
+ "Zip Code": string
41
+ "First Name": string
42
+ "Last Name": 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 createStateIaContractorsAdapter(): CorpusAdapter {
57
+ return {
58
+ id: STATE_IA_CONTRACTORS_ADAPTER_ID,
59
+ defaultLicense: STATE_IA_CONTRACTORS_DEFAULT_LICENSE,
60
+ description:
61
+ "Iowa Active Construction Contractor Registrations — business name + full street address (public-domain).",
62
+
63
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
64
+ if (opts.country && opts.country !== "US") {
65
+ throw new Error(`state-ia-contractors adapter: only US supported, got country=${opts.country}`)
66
+ }
67
+
68
+ const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
69
+ const parser = stream.pipe(
70
+ csvParse({
71
+ columns: true,
72
+ skip_empty_lines: true,
73
+ relax_quotes: true,
74
+ relax_column_count: true,
75
+ })
76
+ )
77
+
78
+ let emitted = 0
79
+
80
+ try {
81
+ for await (const record of parser as AsyncIterable<IaContractorRow>) {
82
+ if (opts.signal?.aborted) break
83
+
84
+ if (opts.limit !== undefined && emitted >= opts.limit) break
85
+
86
+ const businessName = (record["Business Name"] ?? "").trim()
87
+ const address1 = (record["Address 1"] ?? "").trim()
88
+ const address2 = (record["Address 2"] ?? "").trim()
89
+ const city = (record.City ?? "").trim()
90
+ const stateAbbr = (record.State ?? "").trim()
91
+ const zip = (record["Zip Code"] ?? "").trim()
92
+
93
+ if (!city || !zip) continue
94
+
95
+ const state = lookupStateAbbreviation(stateAbbr)
96
+
97
+ if (!state) continue
98
+
99
+ const fullAddress = [address1, address2].filter(Boolean).join(" ")
100
+ const split = splitAddress(fullAddress)
101
+
102
+ if (!split) continue
103
+
104
+ const venue = businessName || undefined
105
+
106
+ const components: CanonicalRow["components"] = {
107
+ ...(venue ? { venue } : {}),
108
+ ...(split.house_number ? { house_number: split.house_number } : {}),
109
+ street: split.street,
110
+ locality: city,
111
+ region: state.abbreviation,
112
+ postcode: zip,
113
+ }
114
+
115
+ const streetPart = [split.house_number, split.street].filter(Boolean).join(" ").trim()
116
+ const raw = [venue, streetPart, [city, [stateAbbr, zip].filter(Boolean).join(" ")].filter(Boolean).join(", ")]
117
+ .filter(Boolean)
118
+ .join(", ")
119
+
120
+ const aligned = reconcileComponents(components, raw)
121
+
122
+ if (Object.keys(aligned).length <= 2) continue
123
+
124
+ const regNum = (record["Registration #"] ?? "").trim()
125
+ const sourceID = regNum
126
+ ? `${STATE_IA_CONTRACTORS_ADAPTER_ID}-${regNum}`
127
+ : stableSourceID(STATE_IA_CONTRACTORS_ADAPTER_ID, aligned)
128
+
129
+ yield {
130
+ raw,
131
+ components: aligned,
132
+ country: "US",
133
+ locale: "en-US",
134
+ source: STATE_IA_CONTRACTORS_ADAPTER_ID,
135
+ source_id: sourceID,
136
+ corpus_version: "",
137
+ license: STATE_IA_CONTRACTORS_DEFAULT_LICENSE,
138
+ }
139
+ emitted++
140
+ }
141
+ } finally {
142
+ stream.destroy()
143
+ }
144
+ },
145
+ }
146
+ }
147
+
148
+ export const stateIaContractorsAdapter = createStateIaContractorsAdapter()
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `state-ny-notaries`: New York Commissioned Notaries CSV consumer.
7
+ *
8
+ * The New York Department of State publishes a registry of commissioned notaries public. Each row
9
+ * optionally carries a business name and business address (~1-5% fill rate).
10
+ *
11
+ * The adapter consumes the CSV the operator pre-downloads via `fetch-state-sources.ts`. Column
12
+ * names match the data.ny.gov export header (note: some columns have leading spaces).
13
+ *
14
+ * License: stamped `"Public Domain"` per New York state government open-data terms.
15
+ */
16
+
17
+ import { createReadStream } from "node:fs"
18
+
19
+ import { parse as csvParse } from "csv-parse"
20
+
21
+ import { stableSourceID } from "../../adapter.ts"
22
+ import { lookupStateAbbreviation } from "../../codex/us-fips-state.ts"
23
+ import { reconcileComponents } from "../../format.ts"
24
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
25
+
26
+ export const STATE_NY_NOTARIES_ADAPTER_ID = "state-ny-notaries"
27
+ export const STATE_NY_NOTARIES_DEFAULT_LICENSE = "Public Domain"
28
+
29
+ const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
30
+
31
+ interface NyNotaryRow {
32
+ "Commission Holder Name": string
33
+ "Commission Number (UID)": string
34
+ "Business Name (if available)": string
35
+ "Business Address 1 (if available)": string
36
+ "Business Address 2 (if available)": string
37
+ " Business City (if available)": string
38
+ "Business State (if available)": string
39
+ "Business Zip (if available)": string
40
+ "Commissioned County": string
41
+ }
42
+
43
+ function splitAddress(address: string): { house_number?: string; street: string } | null {
44
+ const trimmed = address.trim()
45
+
46
+ if (!trimmed) return null
47
+ const m = HOUSE_NUMBER_PREFIX.exec(trimmed)
48
+
49
+ if (m) return { house_number: m[1], street: m[2]!.trim() }
50
+
51
+ return { street: trimmed }
52
+ }
53
+
54
+ const RAW_NY_COLUMNS = [
55
+ "Commission Holder Name",
56
+ "Commission Number (UID)",
57
+ "Business Name (if available)",
58
+ "Business Address 1 (if available)",
59
+ "Business Address 2 (if available)",
60
+ " Business City (if available)",
61
+ "Business State (if available)",
62
+ "Business Zip (if available)",
63
+ "Commissioned County",
64
+ ] as const
65
+
66
+ export function createStateNyNotariesAdapter(): CorpusAdapter {
67
+ return {
68
+ id: STATE_NY_NOTARIES_ADAPTER_ID,
69
+ defaultLicense: STATE_NY_NOTARIES_DEFAULT_LICENSE,
70
+ description: "New York Commissioned Notaries — name + optional business address (public-domain).",
71
+
72
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
73
+ if (opts.country && opts.country !== "US") {
74
+ throw new Error(`state-ny-notaries adapter: only US supported, got country=${opts.country}`)
75
+ }
76
+
77
+ const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
78
+ const parser = stream.pipe(
79
+ csvParse({
80
+ columns: true,
81
+ skip_empty_lines: true,
82
+ relax_quotes: true,
83
+ relax_column_count: true,
84
+ })
85
+ )
86
+
87
+ let emitted = 0
88
+
89
+ try {
90
+ for await (const rawRecord of parser as AsyncIterable<Record<string, string>>) {
91
+ if (opts.signal?.aborted) break
92
+
93
+ if (opts.limit !== undefined && emitted >= opts.limit) break
94
+
95
+ // NY CSV has columns with leading spaces, so we normalize by trimming keys.
96
+ const record: Record<string, string> = {}
97
+
98
+ for (const key of Object.keys(rawRecord)) {
99
+ record[key.trim()] = rawRecord[key] ?? ""
100
+ }
101
+
102
+ const holderName = (record["Commission Holder Name"] ?? "").trim()
103
+ const businessName = (record["Business Name (if available)"] ?? "").trim()
104
+ const address1 = (record["Business Address 1 (if available)"] ?? "").trim()
105
+ const address2 = (record["Business Address 2 (if available)"] ?? "").trim()
106
+ const city = (record["Business City (if available)"] ?? "").trim()
107
+ const stateAbbr = (record["Business State (if available)"] ?? "").trim()
108
+ const zip = (record["Business Zip (if available)"] ?? "").trim()
109
+ const county = (record["Commissioned County"] ?? "").trim()
110
+
111
+ if (!city || !stateAbbr || !zip) continue
112
+
113
+ if (!address1 && !address2) continue
114
+
115
+ const state = lookupStateAbbreviation(stateAbbr)
116
+
117
+ if (!state) continue
118
+
119
+ const fullAddress = [address1, address2].filter(Boolean).join(" ")
120
+ const split = splitAddress(fullAddress)
121
+
122
+ if (!split) continue
123
+
124
+ const venue = businessName || holderName || undefined
125
+
126
+ const components: CanonicalRow["components"] = {
127
+ ...(venue ? { venue } : {}),
128
+ ...(split.house_number ? { house_number: split.house_number } : {}),
129
+ street: split.street,
130
+ locality: city,
131
+ region: state.abbreviation,
132
+ postcode: zip,
133
+ ...(county ? { subregion: county } : {}),
134
+ }
135
+
136
+ const streetPart = [split.house_number, split.street].filter(Boolean).join(" ").trim()
137
+ const raw = [venue, streetPart, [city, [stateAbbr, zip].filter(Boolean).join(" ")].filter(Boolean).join(", ")]
138
+ .filter(Boolean)
139
+ .join(", ")
140
+
141
+ const aligned = reconcileComponents(components, raw)
142
+
143
+ if (Object.keys(aligned).length <= 2) continue
144
+
145
+ const commNum = (record["Commission Number (UID)"] ?? "").trim()
146
+ const sourceID = commNum
147
+ ? `${STATE_NY_NOTARIES_ADAPTER_ID}-${commNum}`
148
+ : stableSourceID(STATE_NY_NOTARIES_ADAPTER_ID, aligned)
149
+
150
+ yield {
151
+ raw,
152
+ components: aligned,
153
+ country: "US",
154
+ locale: "en-US",
155
+ source: STATE_NY_NOTARIES_ADAPTER_ID,
156
+ source_id: sourceID,
157
+ corpus_version: "",
158
+ license: STATE_NY_NOTARIES_DEFAULT_LICENSE,
159
+ }
160
+ emitted++
161
+ }
162
+ } finally {
163
+ stream.destroy()
164
+ }
165
+ },
166
+ }
167
+ }
168
+
169
+ export const stateNyNotariesAdapter = createStateNyNotariesAdapter()