crawlforge-extractors 1.2.2 → 1.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/README.md +92 -3
- package/index.d.ts +73 -4
- package/package.json +19 -4
- package/src/connectors/ats.js +748 -0
- package/src/connectors/gov.js +513 -0
- package/src/templates.js +333 -52
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Government open-data connectors — free, keyless, documented US federal APIs.
|
|
3
|
+
*
|
|
4
|
+
* Two agencies publish exactly the data two of our tool categories were being
|
|
5
|
+
* asked to scrape, and publish it as JSON with no key and no account:
|
|
6
|
+
*
|
|
7
|
+
* nhtsa-vin NHTSA vPIC — decode a VIN to 154 vehicle fields.
|
|
8
|
+
* Docs: https://vpic.nhtsa.dot.gov/api/
|
|
9
|
+
* robots: https://vpic.nhtsa.dot.gov/robots.txt is HTTP 404 —
|
|
10
|
+
* the host publishes none, so nothing is disallowed.
|
|
11
|
+
* (www.nhtsa.gov's robots.txt is a different host and
|
|
12
|
+
* does not govern this one; it answers 403 anyway.)
|
|
13
|
+
* Rates: the API help states vPIC applies "an automated
|
|
14
|
+
* traffic rate control mechanism", so the caller's
|
|
15
|
+
* own polite-rate policy still applies (G6).
|
|
16
|
+
*
|
|
17
|
+
* npi-provider NPPES NPI Registry — US health care provider records.
|
|
18
|
+
* Docs: https://npiregistry.cms.hhs.gov/api-page
|
|
19
|
+
* robots: https://npiregistry.cms.hhs.gov/robots.txt answers
|
|
20
|
+
* HTTP 200 with the site's Angular index.html
|
|
21
|
+
* (content-type: text/html, 406 bytes) rather than a
|
|
22
|
+
* robots file — a soft 404. There are no directives
|
|
23
|
+
* to honour on this host.
|
|
24
|
+
*
|
|
25
|
+
* Both are documented APIs, which is why they are here instead of a scraper
|
|
26
|
+
* pointed at the same agencies' search pages (G3).
|
|
27
|
+
*
|
|
28
|
+
* Templates make no network calls: `listUrl(params)` builds the URL and the
|
|
29
|
+
* caller fetches it under its own SSRF, timeout and billing policy.
|
|
30
|
+
*
|
|
31
|
+
* Every fixture under tests/fixtures/gov/ is condensed from a live capture
|
|
32
|
+
* taken 2026-08-28 with
|
|
33
|
+
* curl -A 'CrawlForge/1.2.4 (+https://crawlforge.dev)'
|
|
34
|
+
* — a car, a truck, a motorcycle, a partial VIN and an undecodable one for
|
|
35
|
+
* vPIC; individuals, organisations and all three error shapes for NPPES.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// ── NHTSA vPIC ───────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const VPIC_BASE = 'https://vpic.nhtsa.dot.gov/api/vehicles';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* vPIC writes "" for a variable it holds no value for — its own response
|
|
44
|
+
* Message says so, and warns that a missing value must not be read as "the
|
|
45
|
+
* feature is unavailable". Around 120 of the 154 fields come back empty on a
|
|
46
|
+
* typical decode, and once "" is sitting in a record a caller cannot tell it
|
|
47
|
+
* from a real value.
|
|
48
|
+
*/
|
|
49
|
+
function vinValue(value) {
|
|
50
|
+
const trimmed = typeof value === 'string' ? value.trim() : value;
|
|
51
|
+
return trimmed === '' || trimmed === undefined ? null : trimmed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ErrorCode is a comma-joined list — "6,7,11,400" on a VIN with invalid
|
|
56
|
+
* characters. "0" is the not-an-error code ("VIN decoded clean"), so an empty
|
|
57
|
+
* list here is the clean decode.
|
|
58
|
+
*
|
|
59
|
+
* The matching ErrorText is surfaced whole rather than split per code: code 7's
|
|
60
|
+
* own description contains a semicolon ("…for use on U.S roads; Please contact
|
|
61
|
+
* the manufacturer directly for more information"), so splitting on the "; "
|
|
62
|
+
* separator yields five parts for four codes.
|
|
63
|
+
*/
|
|
64
|
+
function vinErrorCodes(value) {
|
|
65
|
+
return String(value ?? '')
|
|
66
|
+
.split(',')
|
|
67
|
+
.map(code => code.trim())
|
|
68
|
+
.filter(code => code && code !== '0');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── NPPES NPI Registry ───────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const NPI_ENDPOINT = 'https://npiregistry.cms.hhs.gov/api/';
|
|
74
|
+
|
|
75
|
+
// Versions 1.0 and 2.0 are retired, and so is the unversioned endpoint — the
|
|
76
|
+
// registry answers all three with an "Unsupported Version" error now.
|
|
77
|
+
const NPI_VERSION = '2.1';
|
|
78
|
+
|
|
79
|
+
// Documented ceilings: 200 records per request, skip up to 1000.
|
|
80
|
+
const NPI_MAX_LIMIT = 200;
|
|
81
|
+
const NPI_MAX_SKIP = 1000;
|
|
82
|
+
const NPI_DEFAULT_LIMIT = 10;
|
|
83
|
+
|
|
84
|
+
/** The registry's documented search criteria, in its own spelling. */
|
|
85
|
+
const NPI_SEARCH_FIELDS = [
|
|
86
|
+
'number',
|
|
87
|
+
'enumeration_type',
|
|
88
|
+
'taxonomy_description',
|
|
89
|
+
'name_purpose',
|
|
90
|
+
'first_name',
|
|
91
|
+
'use_first_name_alias',
|
|
92
|
+
'last_name',
|
|
93
|
+
'organization_name',
|
|
94
|
+
'address_purpose',
|
|
95
|
+
'city',
|
|
96
|
+
'state',
|
|
97
|
+
'postal_code',
|
|
98
|
+
'country_code'
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The registry writes "--" where a name part is absent — 1265566509 and
|
|
103
|
+
* 1184624694 both carry name_suffix "--" (captured 2026-08-28). Passed through
|
|
104
|
+
* it renders as a real suffix, so it reads as null, the same way an unset
|
|
105
|
+
* Shopify compare-at price does.
|
|
106
|
+
*/
|
|
107
|
+
function npiNamePart(value) {
|
|
108
|
+
const trimmed = String(value ?? '').trim();
|
|
109
|
+
return trimmed && trimmed !== '--' ? trimmed : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Compose the display name from whichever name fields the record carries.
|
|
114
|
+
*
|
|
115
|
+
* A `basic` block spells the affixes name_prefix/name_suffix; an `other_names`
|
|
116
|
+
* entry spells the same two fields prefix/suffix. Both are read here so one
|
|
117
|
+
* function covers both shapes.
|
|
118
|
+
*/
|
|
119
|
+
function npiName(fields) {
|
|
120
|
+
if (fields.organization_name) return npiNamePart(fields.organization_name);
|
|
121
|
+
const parts = [
|
|
122
|
+
fields.name_prefix ?? fields.prefix,
|
|
123
|
+
fields.first_name,
|
|
124
|
+
fields.middle_name,
|
|
125
|
+
fields.last_name,
|
|
126
|
+
fields.name_suffix ?? fields.suffix
|
|
127
|
+
];
|
|
128
|
+
return parts.map(npiNamePart).filter(Boolean).join(' ') || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** sole_proprietor is "YES"/"NO" on individuals and absent on organisations. */
|
|
132
|
+
function npiYesNo(value) {
|
|
133
|
+
if (value === 'YES') return true;
|
|
134
|
+
if (value === 'NO') return false;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function npiAddress(address) {
|
|
139
|
+
if (!address) return null;
|
|
140
|
+
return {
|
|
141
|
+
purpose: address.address_purpose || null,
|
|
142
|
+
line1: address.address_1 || null,
|
|
143
|
+
line2: address.address_2 || null,
|
|
144
|
+
city: address.city || null,
|
|
145
|
+
state: address.state || null,
|
|
146
|
+
// ZIP+4 arrives unhyphenated ("920204454"), left as the registry writes it.
|
|
147
|
+
postal_code: address.postal_code || null,
|
|
148
|
+
country_code: address.country_code || null,
|
|
149
|
+
country_name: address.country_name || null,
|
|
150
|
+
telephone: address.telephone_number || null,
|
|
151
|
+
fax: address.fax_number || null
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The registry silently caps rather than reporting: limit=1201 answers
|
|
157
|
+
* result_count 200, not an error (verified 2026-08-28). A caller that paginates
|
|
158
|
+
* on the page size it asked for would step over records it never received, so
|
|
159
|
+
* the ceiling is named here instead of quietly applied.
|
|
160
|
+
*/
|
|
161
|
+
function npiBound(name, value, min, max) {
|
|
162
|
+
const number = Number(value);
|
|
163
|
+
if (!Number.isInteger(number) || number < min || number > max) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`npi-provider "${name}" must be an integer between ${min} and ${max}. ` +
|
|
166
|
+
`The NPI registry silently caps anything larger instead of reporting it. Got: ${value}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** limit and skip live in the request; the response echoes neither. */
|
|
173
|
+
function npiPaging(url) {
|
|
174
|
+
let params;
|
|
175
|
+
try {
|
|
176
|
+
params = new URL(url).searchParams;
|
|
177
|
+
} catch {
|
|
178
|
+
params = new URLSearchParams();
|
|
179
|
+
}
|
|
180
|
+
const limit = Number.parseInt(params.get('limit') ?? '', 10);
|
|
181
|
+
const skip = Number.parseInt(params.get('skip') ?? '', 10);
|
|
182
|
+
return {
|
|
183
|
+
limit: Number.isInteger(limit) ? limit : NPI_DEFAULT_LIMIT,
|
|
184
|
+
skip: Number.isInteger(skip) ? skip : 0
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Connector definitions ────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
export const GOV_TEMPLATES = [
|
|
191
|
+
{
|
|
192
|
+
id: 'nhtsa-vin',
|
|
193
|
+
name: 'NHTSA VIN Decode',
|
|
194
|
+
description:
|
|
195
|
+
'Decode a VIN against NHTSA\'s vPIC API: make, model, year, trim, body class, drive type, ' +
|
|
196
|
+
'engine, fuel, transmission, plant and GVWR, plus the full 154-field decode under `raw`. ' +
|
|
197
|
+
'Free, keyless and authoritative — the catalogue is built from the manufacturers\' own Part ' +
|
|
198
|
+
'565 submissions, so nothing is inferred or guessed. Partial VINs are accepted ("*" for ' +
|
|
199
|
+
'unknown positions), and vPIC\'s own error codes are reported rather than swallowed.',
|
|
200
|
+
targetPattern: /vpic\.nhtsa\.dot\.gov\/api\/vehicles\/DecodeVin/i,
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {{ vin: string, modelYear?: string|number }} params
|
|
204
|
+
*/
|
|
205
|
+
listUrl({ vin, modelYear } = {}) {
|
|
206
|
+
const value = typeof vin === 'string' ? vin.trim() : '';
|
|
207
|
+
if (!value) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
'nhtsa-vin requires a "vin" parameter: the VIN to decode. A partial VIN is allowed — ' +
|
|
210
|
+
'vPIC accepts "*" for unknown positions, as in 5UXWX7C5*BA.'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const url = new URL(`${VPIC_BASE}/DecodeVinValues/${encodeURIComponent(value)}`);
|
|
214
|
+
url.searchParams.set('format', 'json');
|
|
215
|
+
// vPIC documents modelyear as improving decode accuracy, and says so in
|
|
216
|
+
// the response itself when it matters: a VIN whose 10th position is
|
|
217
|
+
// ambiguous comes back with "The Model Year decoded for this VIN may be
|
|
218
|
+
// incorrect. If you know the Model year, please enter it and decode again".
|
|
219
|
+
if (modelYear !== undefined && modelYear !== null && modelYear !== '') {
|
|
220
|
+
url.searchParams.set('modelyear', String(modelYear));
|
|
221
|
+
}
|
|
222
|
+
return url.toString();
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Point the fetch at the flat DecodeVinValues endpoint, in JSON.
|
|
227
|
+
*
|
|
228
|
+
* vPIC has four decode endpoints and every one of them defaults to XML.
|
|
229
|
+
* DecodeVin and DecodeVinExtended return a [{ Variable, Value }] list this
|
|
230
|
+
* template does not read, so a caller pasting a URL straight out of the
|
|
231
|
+
* documentation (…/DecodeVin/5UXWX7C5*BA?format=xml&modelyear=2011) would
|
|
232
|
+
* otherwise fetch a body extractRaw rejects.
|
|
233
|
+
*/
|
|
234
|
+
resolveUrl(url) {
|
|
235
|
+
let parsed;
|
|
236
|
+
try {
|
|
237
|
+
parsed = new URL(url);
|
|
238
|
+
} catch {
|
|
239
|
+
return url;
|
|
240
|
+
}
|
|
241
|
+
const match = parsed.pathname.match(
|
|
242
|
+
/^(.*\/api\/vehicles\/)DecodeVin(?:Values)?(?:Extended)?\/(.+)$/i
|
|
243
|
+
);
|
|
244
|
+
if (!match) return url;
|
|
245
|
+
parsed.pathname = `${match[1]}DecodeVinValues/${match[2]}`;
|
|
246
|
+
parsed.searchParams.set('format', 'json');
|
|
247
|
+
return parsed.toString();
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
extractRaw(body, url) {
|
|
251
|
+
let payload;
|
|
252
|
+
try {
|
|
253
|
+
payload = JSON.parse(body);
|
|
254
|
+
} catch {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Not a vPIC decode response: ${url} did not return JSON. ` +
|
|
257
|
+
'This template reads the NHTSA vPIC DecodeVinValues API.'
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const result = Array.isArray(payload?.Results) ? payload.Results[0] : null;
|
|
262
|
+
// A parseable body is not proof of a decode: vPIC answers a missing VIN
|
|
263
|
+
// with a 404 whose body is JSON — {"message":"No HTTP resource was found
|
|
264
|
+
// that matches the request URI …"}. ErrorCode is the flat shape's
|
|
265
|
+
// discriminator; the nested DecodeVin shape has no such key.
|
|
266
|
+
if (!result || !('ErrorCode' in result)) {
|
|
267
|
+
const reason = typeof payload?.message === 'string'
|
|
268
|
+
? payload.message
|
|
269
|
+
: 'JSON without a decoded vehicle';
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Not a vPIC decode response: ${url} returned ${reason}. ` +
|
|
272
|
+
'This template reads the NHTSA vPIC DecodeVinValues API.'
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The whole decode, empties normalised, so nothing vPIC returned is lost
|
|
277
|
+
// by the curated shape below. "Not Applicable" is left as written — it is
|
|
278
|
+
// vPIC's real answer for a variable that does not apply to this class of
|
|
279
|
+
// vehicle (BusType on a pickup), not a missing value.
|
|
280
|
+
const raw = Object.fromEntries(
|
|
281
|
+
Object.entries(result).map(([key, value]) => [key, vinValue(value)])
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
vin: raw.VIN,
|
|
286
|
+
// The VIN with the serial positions masked — what vPIC actually keyed
|
|
287
|
+
// the lookup on, and safe to log where the full VIN is not.
|
|
288
|
+
vehicle_descriptor: raw.VehicleDescriptor,
|
|
289
|
+
|
|
290
|
+
make: raw.Make,
|
|
291
|
+
model: raw.Model,
|
|
292
|
+
model_year: raw.ModelYear,
|
|
293
|
+
trim: raw.Trim,
|
|
294
|
+
series: raw.Series,
|
|
295
|
+
body_class: raw.BodyClass,
|
|
296
|
+
vehicle_type: raw.VehicleType,
|
|
297
|
+
doors: raw.Doors,
|
|
298
|
+
drive_type: raw.DriveType,
|
|
299
|
+
gvwr: raw.GVWR,
|
|
300
|
+
|
|
301
|
+
engine_cylinders: raw.EngineCylinders,
|
|
302
|
+
engine_displacement_l: raw.DisplacementL,
|
|
303
|
+
engine_hp: raw.EngineHP,
|
|
304
|
+
engine_configuration: raw.EngineConfiguration,
|
|
305
|
+
engine_model: raw.EngineModel,
|
|
306
|
+
engine_manufacturer: raw.EngineManufacturer,
|
|
307
|
+
fuel_type_primary: raw.FuelTypePrimary,
|
|
308
|
+
fuel_type_secondary: raw.FuelTypeSecondary,
|
|
309
|
+
transmission_style: raw.TransmissionStyle,
|
|
310
|
+
transmission_speeds: raw.TransmissionSpeeds,
|
|
311
|
+
|
|
312
|
+
manufacturer: raw.Manufacturer,
|
|
313
|
+
plant_city: raw.PlantCity,
|
|
314
|
+
plant_state: raw.PlantState,
|
|
315
|
+
plant_country: raw.PlantCountry,
|
|
316
|
+
|
|
317
|
+
// vPIC decodes partially and says so in the payload rather than in the
|
|
318
|
+
// HTTP status: a wrong check digit (code 1) still yields a full, usable
|
|
319
|
+
// decode, while invalid characters (code 400) yield make and model of
|
|
320
|
+
// null. A caller has to be able to see which it got.
|
|
321
|
+
decode_errors: {
|
|
322
|
+
codes: vinErrorCodes(raw.ErrorCode),
|
|
323
|
+
text: raw.ErrorText,
|
|
324
|
+
additional_text: raw.AdditionalErrorText,
|
|
325
|
+
suggested_vin: raw.SuggestedVIN
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
raw
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
{
|
|
334
|
+
id: 'npi-provider',
|
|
335
|
+
name: 'NPI Provider Registry',
|
|
336
|
+
description:
|
|
337
|
+
'Search the NPPES NPI Registry — the public US health care provider registry CMS publishes — ' +
|
|
338
|
+
'by NPI number, name, organisation, taxonomy/specialty or location. Returns registry records ' +
|
|
339
|
+
'as the registry publishes them: NPI, individual-or-organisation type, credential, status, ' +
|
|
340
|
+
'taxonomies with the primary one flagged, and practice and mailing addresses kept apart. ' +
|
|
341
|
+
'Free and keyless. It is a registry lookup, not a people-search: it returns one record per ' +
|
|
342
|
+
'NPI and joins nothing to it.',
|
|
343
|
+
targetPattern: /npiregistry\.cms\.hhs\.gov\/api/i,
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @param {Record<string, string|number>} params — the registry's own search fields
|
|
347
|
+
*/
|
|
348
|
+
listUrl(params = {}) {
|
|
349
|
+
const url = new URL(NPI_ENDPOINT);
|
|
350
|
+
url.searchParams.set('version', NPI_VERSION);
|
|
351
|
+
|
|
352
|
+
let criteria = 0;
|
|
353
|
+
for (const field of NPI_SEARCH_FIELDS) {
|
|
354
|
+
const value = params[field];
|
|
355
|
+
if (value === undefined || value === null || value === '') continue;
|
|
356
|
+
url.searchParams.set(field, String(value));
|
|
357
|
+
criteria += 1;
|
|
358
|
+
}
|
|
359
|
+
if (criteria === 0) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
'npi-provider requires at least one search criterion. Accepted: ' +
|
|
362
|
+
`${NPI_SEARCH_FIELDS.join(', ')}. The registry answers a bare query with HTTP 200 and ` +
|
|
363
|
+
'{"Errors":[{"description":"No valid search criteria provided"}]}.'
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The registry has further rules of its own — `state` and
|
|
368
|
+
// `enumeration_type` cannot stand alone, `country_code` can only when it
|
|
369
|
+
// is not US. Those are not re-implemented here: it enforces them itself,
|
|
370
|
+
// and extractList turns its answer into a named error.
|
|
371
|
+
if (params.limit !== undefined && params.limit !== null && params.limit !== '') {
|
|
372
|
+
url.searchParams.set('limit', String(npiBound('limit', params.limit, 1, NPI_MAX_LIMIT)));
|
|
373
|
+
}
|
|
374
|
+
if (params.skip !== undefined && params.skip !== null && params.skip !== '') {
|
|
375
|
+
url.searchParams.set('skip', String(npiBound('skip', params.skip, 0, NPI_MAX_SKIP)));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return url.toString();
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
extractList(body, url) {
|
|
382
|
+
let payload;
|
|
383
|
+
try {
|
|
384
|
+
payload = JSON.parse(body);
|
|
385
|
+
} catch {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Not an NPI registry response: ${url} did not return JSON. ` +
|
|
388
|
+
'This connector reads the NPPES NPI Registry API.'
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// The registry reports every error with HTTP 200 and an Errors array — a
|
|
393
|
+
// bare query, a retired version, a state with no second criterion. Left
|
|
394
|
+
// alone it would fall through to an empty item list, which reads as "no
|
|
395
|
+
// such provider" when the truth is "the query was never run".
|
|
396
|
+
if (Array.isArray(payload?.Errors) && payload.Errors.length) {
|
|
397
|
+
const detail = payload.Errors
|
|
398
|
+
.map(error => [error.field, error.description].filter(Boolean).join(': '))
|
|
399
|
+
.join('; ');
|
|
400
|
+
throw new Error(`NPI registry rejected the query for ${url}: ${detail}.`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!Array.isArray(payload?.results)) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Not an NPI registry response: ${url} returned JSON without a results array. ` +
|
|
406
|
+
'This connector reads the NPPES NPI Registry API.'
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const items = payload.results.map(record => {
|
|
411
|
+
const basic = record.basic || {};
|
|
412
|
+
const addresses = Array.isArray(record.addresses) ? record.addresses : [];
|
|
413
|
+
|
|
414
|
+
// The registry's API help states "the first address in the array will
|
|
415
|
+
// always be the Primary Practice Location and the second address in the
|
|
416
|
+
// array will always be the Mailing Address". It is not true: across the
|
|
417
|
+
// 233 records captured 2026-08-28, 118 (50.6%) put MAILING first.
|
|
418
|
+
// Reading addresses[0] as the practice location therefore returns a
|
|
419
|
+
// mailing address — for a sole proprietor, often a home address — about
|
|
420
|
+
// half the time. Both are keyed by address_purpose, never by index.
|
|
421
|
+
const location = addresses.find(a => a.address_purpose === 'LOCATION');
|
|
422
|
+
const mailing = addresses.find(a => a.address_purpose === 'MAILING');
|
|
423
|
+
|
|
424
|
+
const taxonomies = (record.taxonomies || []).map(taxonomy => ({
|
|
425
|
+
code: taxonomy.code || null,
|
|
426
|
+
description: taxonomy.desc || null,
|
|
427
|
+
primary: taxonomy.primary === true,
|
|
428
|
+
license: taxonomy.license || null,
|
|
429
|
+
state: taxonomy.state || null,
|
|
430
|
+
// "" on most individuals, "193400000X - Single Specialty Group" on a
|
|
431
|
+
// record that belongs to one.
|
|
432
|
+
group: taxonomy.taxonomy_group || null
|
|
433
|
+
}));
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
npi: record.number || null,
|
|
437
|
+
// NPI-1 is an individual provider, NPI-2 an organisation.
|
|
438
|
+
enumeration_type: record.enumeration_type || null,
|
|
439
|
+
name: npiName(basic),
|
|
440
|
+
first_name: npiNamePart(basic.first_name),
|
|
441
|
+
last_name: npiNamePart(basic.last_name),
|
|
442
|
+
organization_name: npiNamePart(basic.organization_name),
|
|
443
|
+
credential: npiNamePart(basic.credential),
|
|
444
|
+
sole_proprietor: npiYesNo(basic.sole_proprietor),
|
|
445
|
+
// "A" is active; the registry keeps deactivated records readable.
|
|
446
|
+
status: basic.status || null,
|
|
447
|
+
enumeration_date: basic.enumeration_date || null,
|
|
448
|
+
last_updated: basic.last_updated || null,
|
|
449
|
+
|
|
450
|
+
// A taxonomy_description search matches ANY of a provider's up-to-15
|
|
451
|
+
// taxonomies, not the primary one: NPI 1982227625 answers a Cardiology
|
|
452
|
+
// search with a primary taxonomy of "Pharmacist, Ambulatory Care".
|
|
453
|
+
// Without this field a caller reads the result set as "cardiologists".
|
|
454
|
+
primary_taxonomy: taxonomies.find(t => t.primary)?.description ?? null,
|
|
455
|
+
taxonomies,
|
|
456
|
+
|
|
457
|
+
addresses: {
|
|
458
|
+
location: npiAddress(location),
|
|
459
|
+
mailing: npiAddress(mailing)
|
|
460
|
+
},
|
|
461
|
+
// Additional practice sites, which the registry keeps in its own array
|
|
462
|
+
// rather than in `addresses`.
|
|
463
|
+
practice_locations: (record.practiceLocations || []).map(npiAddress),
|
|
464
|
+
|
|
465
|
+
other_names: (record.other_names || []).map(other => ({
|
|
466
|
+
type: other.type || null,
|
|
467
|
+
name: npiName(other)
|
|
468
|
+
}))
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const { limit, skip } = npiPaging(url);
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
items,
|
|
476
|
+
count: items.length,
|
|
477
|
+
limit,
|
|
478
|
+
skip,
|
|
479
|
+
// The registry publishes no total. result_count is the size of THIS
|
|
480
|
+
// page, not of the match: the same CA/Internal Medicine query answered
|
|
481
|
+
// result_count 5 at limit=5 and 200 at limit=200 (2026-08-28). So there
|
|
482
|
+
// is no total to report, and a full page is the only "there may be more"
|
|
483
|
+
// signal the API gives — the registry's own stop condition is a page
|
|
484
|
+
// shorter than the limit.
|
|
485
|
+
more_possible: items.length === limit
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* G8 — npi-provider is a registry passthrough, not a profile builder.
|
|
493
|
+
*
|
|
494
|
+
* NPPES is a public professional registry: CMS publishes every NPI record for
|
|
495
|
+
* anyone to read, and this connector returns those records as the registry
|
|
496
|
+
* writes them. It has no enrichment hook, no join against any other source and
|
|
497
|
+
* no "everything about this person" shape — the query surface is the registry's
|
|
498
|
+
* own documented search, and the output is one record per NPI.
|
|
499
|
+
*
|
|
500
|
+
* Sole-proprietor records (NPI-1) carry an individual's name, credential and
|
|
501
|
+
* practice address by the registry's own design. That is registry data being
|
|
502
|
+
* passed through, not a profile being assembled.
|
|
503
|
+
*
|
|
504
|
+
* Two fields the registry does publish are deliberately left in it, because
|
|
505
|
+
* neither is professional-registry information a provider lookup needs:
|
|
506
|
+
* basic.sex a personal demographic attribute
|
|
507
|
+
* basic.authorized_official_* the name, title and direct telephone number of
|
|
508
|
+
* the individual who signed for an organisation
|
|
509
|
+
* Anyone who needs them can read the registry. This connector is not the tool
|
|
510
|
+
* that collects them into a picture of a person.
|
|
511
|
+
*/
|
|
512
|
+
|
|
513
|
+
export default GOV_TEMPLATES;
|