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.
@@ -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;