crawlforge-extractors 1.2.3 → 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 +315 -51
package/src/templates.js
CHANGED
|
@@ -20,10 +20,32 @@
|
|
|
20
20
|
* scraping the rendered page, without taking the fetch into its own hands:
|
|
21
21
|
* resolveUrl(url) — rewrite the URL the tool should fetch
|
|
22
22
|
* extractRaw(body,url) — parse the response itself, instead of extract($)
|
|
23
|
+
*
|
|
24
|
+
* Two more turn a template into a *list connector*, returning N entities from
|
|
25
|
+
* one call rather than one entity from one page:
|
|
26
|
+
* listUrl(params) — build the URL to fetch from a plain params object.
|
|
27
|
+
* Throws naming the parameter when a required one is
|
|
28
|
+
* missing.
|
|
29
|
+
* extractList(body,url) — parse the response into { items, count, … }.
|
|
30
|
+
* Defining extractList is the only signal that a template is a list connector;
|
|
31
|
+
* there is no `kind` field. A list connector may also define
|
|
32
|
+
* resolveUrl/targetPattern, so a caller can pass a URL instead of params.
|
|
33
|
+
*
|
|
34
|
+
* A connector against a key-based API declares it:
|
|
35
|
+
* requiresApiKey: true
|
|
36
|
+
* credentialRef: 'SOME_ENV_VAR' — the env var the CONSUMER reads
|
|
37
|
+
* The registry stays pure and never touches process.env. The key arrives as
|
|
38
|
+
* params.apiKey, and listUrl throws naming credentialRef when it is absent.
|
|
23
39
|
*/
|
|
24
40
|
|
|
25
41
|
import { load } from 'cheerio';
|
|
26
42
|
|
|
43
|
+
// Connector families live in their own files — the job-board and government-API
|
|
44
|
+
// sets each carry their own helpers and fixtures, and keeping them here would
|
|
45
|
+
// have made one file the whole package.
|
|
46
|
+
import { ATS_TEMPLATES } from './connectors/ats.js';
|
|
47
|
+
import { GOV_TEMPLATES } from './connectors/gov.js';
|
|
48
|
+
|
|
27
49
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
28
50
|
|
|
29
51
|
function text($, sel) {
|
|
@@ -42,6 +64,15 @@ function listAttr($, sel, attribute) {
|
|
|
42
64
|
return $(sel).map((_, el) => $(el).attr(attribute)).get().filter(Boolean);
|
|
43
65
|
}
|
|
44
66
|
|
|
67
|
+
/** A URL a caller supplied is not guaranteed to be one. Returns null instead of throwing. */
|
|
68
|
+
function safeUrl(url) {
|
|
69
|
+
try {
|
|
70
|
+
return new URL(url);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
// ── Shopify helpers ──────────────────────────────────────────────────────────
|
|
46
77
|
|
|
47
78
|
/** Shopify writes an absent compare-at price as "" rather than omitting it. */
|
|
@@ -90,6 +121,68 @@ function htmlToText(html) {
|
|
|
90
121
|
return text || null;
|
|
91
122
|
}
|
|
92
123
|
|
|
124
|
+
/**
|
|
125
|
+
* One product, from either Shopify endpoint.
|
|
126
|
+
*
|
|
127
|
+
* /products/<handle>.json and /collections/<handle>/products.json serve the
|
|
128
|
+
* same product object with two differences, both handled here rather than by
|
|
129
|
+
* two copies of this mapping that would drift:
|
|
130
|
+
* - the collection endpoint carries a real `available` boolean per variant
|
|
131
|
+
* and no inventory counts; the product endpoint is the reverse, which is
|
|
132
|
+
* what variantAvailable() already reconciles.
|
|
133
|
+
* - neither endpoint is guaranteed to carry price_currency (the collection
|
|
134
|
+
* endpoint never does, verified 2026-08-28 against deathwishcoffee.com and
|
|
135
|
+
* allbirds.com), so currency is null rather than assumed.
|
|
136
|
+
*/
|
|
137
|
+
function shopifyProductEntity(product) {
|
|
138
|
+
const variants = (product.variants || []).map(v => ({
|
|
139
|
+
id: v.id,
|
|
140
|
+
title: v.title,
|
|
141
|
+
price: money(v.price),
|
|
142
|
+
compare_at_price: compareAtPrice(v.compare_at_price),
|
|
143
|
+
sku: v.sku || null,
|
|
144
|
+
available: variantAvailable(v),
|
|
145
|
+
inventory_quantity: typeof v.inventory_quantity === 'number' ? v.inventory_quantity : null,
|
|
146
|
+
options: [v.option1, v.option2, v.option3].filter(Boolean)
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
const prices = variants.map(v => Number.parseFloat(v.price)).filter(Number.isFinite);
|
|
150
|
+
const first = variants[0] || {};
|
|
151
|
+
const availability = variants.map(v => v.available);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
title: product.title || null,
|
|
155
|
+
vendor: product.vendor || null,
|
|
156
|
+
product_type: product.product_type || null,
|
|
157
|
+
handle: product.handle || null,
|
|
158
|
+
product_id: product.id ?? null,
|
|
159
|
+
|
|
160
|
+
// Headline price is the first variant's, matching what the product page
|
|
161
|
+
// shows before a selection is made.
|
|
162
|
+
price: first.price ?? null,
|
|
163
|
+
compare_at_price: first.compare_at_price ?? null,
|
|
164
|
+
// A compare-at price above the price is what renders as a sale badge.
|
|
165
|
+
on_sale: first.compare_at_price !== null && first.compare_at_price !== undefined
|
|
166
|
+
? Number.parseFloat(first.compare_at_price) > Number.parseFloat(first.price)
|
|
167
|
+
: false,
|
|
168
|
+
currency: product.variants?.[0]?.price_currency || null,
|
|
169
|
+
price_min: prices.length ? String(Math.min(...prices).toFixed(2)) : null,
|
|
170
|
+
price_max: prices.length ? String(Math.max(...prices).toFixed(2)) : null,
|
|
171
|
+
|
|
172
|
+
available: availability.some(a => a === true) ? true
|
|
173
|
+
: availability.every(a => a === false) ? false
|
|
174
|
+
: null,
|
|
175
|
+
variants,
|
|
176
|
+
options: (product.options || []).map(o => o.name),
|
|
177
|
+
|
|
178
|
+
description: htmlToText(product.body_html),
|
|
179
|
+
tags: normalizeTags(product.tags),
|
|
180
|
+
images: (product.images || []).map(i => i.src),
|
|
181
|
+
published_at: product.published_at || null,
|
|
182
|
+
updated_at: product.updated_at || null
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
93
186
|
// ── Amazon helpers ───────────────────────────────────────────────────────────
|
|
94
187
|
|
|
95
188
|
/** Amazon's server-side templates leave runs of whitespace and newlines inline. */
|
|
@@ -287,51 +380,126 @@ export const TEMPLATES = [
|
|
|
287
380
|
);
|
|
288
381
|
}
|
|
289
382
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
383
|
+
return shopifyProductEntity(product);
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
{
|
|
388
|
+
id: 'shopify-collection',
|
|
389
|
+
name: 'Shopify Collection',
|
|
390
|
+
description:
|
|
391
|
+
'List every product in a Shopify collection from the store\'s own ' +
|
|
392
|
+
'/collections/<handle>/products.json endpoint: exact price, compare-at price and stock for ' +
|
|
393
|
+
'each product, in one call. Same authoritative source as shopify-product, so a collection ' +
|
|
394
|
+
'and a product page cannot disagree. Pass a collection URL, or the store and collection ' +
|
|
395
|
+
'handle as params. Shopify serves 30 products per page by default and 250 at most, so a ' +
|
|
396
|
+
'large collection needs paging.',
|
|
397
|
+
// Same reasoning as shopify-product: Shopify runs on millions of custom
|
|
398
|
+
// domains, so the URL shape is the only signal. Bounded so it does not also
|
|
399
|
+
// claim /collections/<handle>/products/<handle>, which is a product page.
|
|
400
|
+
targetPattern: /\/collections\/[^/?#]+(?:\/products\.json)?\/?(?:[?#]|$)/i,
|
|
401
|
+
|
|
402
|
+
/** Point the fetch at the collection's product listing. */
|
|
403
|
+
resolveUrl(url) {
|
|
404
|
+
const parsed = new URL(url);
|
|
405
|
+
const match = parsed.pathname.match(/^(.*\/collections\/[^/]+?)(?:\/products(?:\.json)?)?\/?$/i);
|
|
406
|
+
if (!match) return url;
|
|
407
|
+
parsed.pathname = `${match[1]}/products.json`;
|
|
408
|
+
// Keep only the two paging params the endpoint understands. sort_by and
|
|
409
|
+
// filter.* are storefront-rendering concerns the JSON endpoint ignores,
|
|
410
|
+
// and several stores disallow those URLs in robots.txt.
|
|
411
|
+
const paging = new URLSearchParams();
|
|
412
|
+
for (const key of ['limit', 'page']) {
|
|
413
|
+
if (parsed.searchParams.has(key)) paging.set(key, parsed.searchParams.get(key));
|
|
414
|
+
}
|
|
415
|
+
parsed.search = paging.toString();
|
|
416
|
+
parsed.hash = '';
|
|
417
|
+
return parsed.toString();
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Build the listing URL from params instead of a URL.
|
|
422
|
+
* @param {{ store: string, collection: string, limit?: number, page?: number }} params
|
|
423
|
+
*/
|
|
424
|
+
listUrl(params = {}) {
|
|
425
|
+
const { store, collection, limit, page } = params;
|
|
426
|
+
if (!store) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
'shopify-collection requires a "store" parameter: the storefront domain, ' +
|
|
429
|
+
'e.g. "www.allbirds.com".'
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
if (!collection) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
'shopify-collection requires a "collection" parameter: the collection handle, ' +
|
|
435
|
+
'e.g. "mens" from https://www.allbirds.com/collections/mens.'
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const origin = /^https?:\/\//i.test(store) ? store : `https://${store}`;
|
|
440
|
+
const url = new URL(`/collections/${encodeURIComponent(collection)}/products.json`, origin);
|
|
441
|
+
|
|
442
|
+
if (limit !== undefined) {
|
|
443
|
+
const value = Number(limit);
|
|
444
|
+
// Shopify caps the endpoint at 250 and silently truncates past it —
|
|
445
|
+
// saying so beats returning 250 rows to a caller who asked for 1000.
|
|
446
|
+
if (!Number.isInteger(value) || value < 1 || value > 250) {
|
|
447
|
+
throw new Error(`shopify-collection "limit" must be an integer from 1 to 250, got ${limit}.`);
|
|
448
|
+
}
|
|
449
|
+
url.searchParams.set('limit', String(value));
|
|
450
|
+
}
|
|
451
|
+
if (page !== undefined) {
|
|
452
|
+
const value = Number(page);
|
|
453
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
454
|
+
throw new Error(`shopify-collection "page" must be an integer of 1 or more, got ${page}.`);
|
|
455
|
+
}
|
|
456
|
+
url.searchParams.set('page', String(value));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return url.toString();
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
extractList(body, url) {
|
|
463
|
+
let payload;
|
|
464
|
+
try {
|
|
465
|
+
payload = JSON.parse(body);
|
|
466
|
+
} catch {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`Not a Shopify collection endpoint: ${url} did not return JSON. ` +
|
|
469
|
+
'This template only works on Shopify storefronts.'
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!payload || !Array.isArray(payload.products)) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`Not a Shopify collection endpoint: ${url} returned JSON without a products array. ` +
|
|
476
|
+
'This template only works on Shopify storefronts.'
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// An unknown collection handle and a page past the end both answer 200
|
|
481
|
+
// with {"products":[]}, so an empty list is a real answer, not an error.
|
|
482
|
+
const parsed = safeUrl(url);
|
|
483
|
+
const requested = Number.parseInt(parsed?.searchParams.get('limit') ?? '', 10);
|
|
484
|
+
// The store default when no limit is sent, confirmed 2026-08-28.
|
|
485
|
+
const limit = Number.isInteger(requested) ? requested : 30;
|
|
486
|
+
const page = Number.parseInt(parsed?.searchParams.get('page') ?? '', 10) || 1;
|
|
304
487
|
|
|
305
488
|
return {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
currency: product.variants[0]?.price_currency || null,
|
|
321
|
-
price_min: prices.length ? String(Math.min(...prices).toFixed(2)) : null,
|
|
322
|
-
price_max: prices.length ? String(Math.max(...prices).toFixed(2)) : null,
|
|
323
|
-
|
|
324
|
-
available: availability.some(a => a === true) ? true
|
|
325
|
-
: availability.every(a => a === false) ? false
|
|
326
|
-
: null,
|
|
327
|
-
variants,
|
|
328
|
-
options: (product.options || []).map(o => o.name),
|
|
329
|
-
|
|
330
|
-
description: htmlToText(product.body_html),
|
|
331
|
-
tags: normalizeTags(product.tags),
|
|
332
|
-
images: (product.images || []).map(i => i.src),
|
|
333
|
-
published_at: product.published_at || null,
|
|
334
|
-
updated_at: product.updated_at || null
|
|
489
|
+
collection: parsed?.pathname.match(/\/collections\/([^/]+)/i)?.[1] ?? null,
|
|
490
|
+
items: payload.products.map(product => ({
|
|
491
|
+
...shopifyProductEntity(product),
|
|
492
|
+
// A list is only useful if each row is addressable.
|
|
493
|
+
url: parsed && product.handle
|
|
494
|
+
? new URL(`/products/${product.handle}`, parsed.origin).toString()
|
|
495
|
+
: null
|
|
496
|
+
})),
|
|
497
|
+
count: payload.products.length,
|
|
498
|
+
page,
|
|
499
|
+
limit,
|
|
500
|
+
// The endpoint publishes no total, so a full page is the only "there
|
|
501
|
+
// may be more" signal there is.
|
|
502
|
+
has_more: payload.products.length === limit
|
|
335
503
|
};
|
|
336
504
|
}
|
|
337
505
|
},
|
|
@@ -641,26 +809,78 @@ export const TEMPLATES = [
|
|
|
641
809
|
};
|
|
642
810
|
}
|
|
643
811
|
}
|
|
812
|
+
,
|
|
813
|
+
|
|
814
|
+
...ATS_TEMPLATES,
|
|
815
|
+
...GOV_TEMPLATES
|
|
644
816
|
];
|
|
645
817
|
|
|
646
818
|
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
647
819
|
|
|
820
|
+
/**
|
|
821
|
+
* Whether a pattern is anchored to a particular host.
|
|
822
|
+
*
|
|
823
|
+
* Decided by swapping the host out and re-testing: /amazon\.(com|…)/ stops
|
|
824
|
+
* matching, shopify-product's /\/products\/[^/?#]+/ keeps matching. Reading the
|
|
825
|
+
* regex source for a dotted domain instead would misread any pattern that
|
|
826
|
+
* mentions a file — shopify-collection's own /\/products\.json/ has a dot in it
|
|
827
|
+
* and names no host at all.
|
|
828
|
+
*/
|
|
829
|
+
function isHostAnchored(pattern, url) {
|
|
830
|
+
const probe = safeUrl(url);
|
|
831
|
+
if (!probe) return false;
|
|
832
|
+
probe.hostname = 'invalid-host';
|
|
833
|
+
return !pattern.test(probe.toString());
|
|
834
|
+
}
|
|
835
|
+
|
|
648
836
|
export class TemplateRegistry {
|
|
649
|
-
|
|
650
|
-
|
|
837
|
+
/**
|
|
838
|
+
* @param {object[]} [templates] — the template set, injectable so a test can
|
|
839
|
+
* register a fixture without shipping it.
|
|
840
|
+
*/
|
|
841
|
+
constructor(templates = TEMPLATES) {
|
|
842
|
+
this._order = templates;
|
|
843
|
+
this._templates = new Map(templates.map(t => [t.id, t]));
|
|
651
844
|
}
|
|
652
845
|
|
|
653
846
|
/**
|
|
654
|
-
* List all registered
|
|
655
|
-
*
|
|
847
|
+
* List all registered templates. `mode`, `requires_api_key` and
|
|
848
|
+
* `credential_ref` are derived from the template, never stored on it.
|
|
656
849
|
*/
|
|
657
850
|
list() {
|
|
658
|
-
return
|
|
659
|
-
id
|
|
660
|
-
|
|
851
|
+
return this._order.map(t => ({
|
|
852
|
+
id: t.id,
|
|
853
|
+
name: t.name,
|
|
854
|
+
description: t.description,
|
|
855
|
+
// A params-only connector may have no URL shape to advertise.
|
|
856
|
+
targetPattern: t.targetPattern ? t.targetPattern.toString() : null,
|
|
857
|
+
// extractList is the only signal that a template returns N entities.
|
|
858
|
+
mode: t.extractList ? 'list' : 'entity',
|
|
859
|
+
...(t.requiresApiKey ? { requires_api_key: true } : {}),
|
|
860
|
+
...(t.credentialRef ? { credential_ref: t.credentialRef } : {})
|
|
661
861
|
}));
|
|
662
862
|
}
|
|
663
863
|
|
|
864
|
+
/**
|
|
865
|
+
* Pick the template that handles a URL, or null when none does.
|
|
866
|
+
*
|
|
867
|
+
* Deterministic: a template whose pattern names a host outranks one that only
|
|
868
|
+
* matches a path shape, so amazon-product wins an Amazon URL that happens to
|
|
869
|
+
* contain /products/. Remaining ties go to registration order.
|
|
870
|
+
*
|
|
871
|
+
* @param {string} url
|
|
872
|
+
* @returns {object|null}
|
|
873
|
+
*/
|
|
874
|
+
detect(url) {
|
|
875
|
+
if (typeof url !== 'string' || !url) return null;
|
|
876
|
+
|
|
877
|
+
const matches = this._order.filter(t => t.targetPattern?.test(url));
|
|
878
|
+
if (matches.length < 2) return matches[0] ?? null;
|
|
879
|
+
|
|
880
|
+
const hostAnchored = matches.filter(t => isHostAnchored(t.targetPattern, url));
|
|
881
|
+
return (hostAnchored.length ? hostAnchored : matches)[0];
|
|
882
|
+
}
|
|
883
|
+
|
|
664
884
|
/**
|
|
665
885
|
* Look up a template by ID.
|
|
666
886
|
* @param {string} id
|
|
@@ -681,7 +901,17 @@ export class TemplateRegistry {
|
|
|
681
901
|
async run(id, body, url, fetchedUrl = url) {
|
|
682
902
|
const template = this.get(id);
|
|
683
903
|
if (!template) {
|
|
684
|
-
throw new Error(`Unknown template: "${id}". Available: ${
|
|
904
|
+
throw new Error(`Unknown template: "${id}". Available: ${this._order.map(t => t.id).join(', ')}`);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// The mirror of runList()'s guard. Without it a list connector reaches
|
|
908
|
+
// template.extract, which it does not define, and the caller gets
|
|
909
|
+
// "template.extract is not a function" instead of being told which method
|
|
910
|
+
// to call.
|
|
911
|
+
if (!template.extractRaw && !template.extract) {
|
|
912
|
+
throw new Error(
|
|
913
|
+
`Template "${id}" returns a list, not a single entity. Use runList() instead.`
|
|
914
|
+
);
|
|
685
915
|
}
|
|
686
916
|
|
|
687
917
|
const data = template.extractRaw
|
|
@@ -697,6 +927,40 @@ export class TemplateRegistry {
|
|
|
697
927
|
extractedAt: new Date().toISOString()
|
|
698
928
|
};
|
|
699
929
|
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Run a list connector against a fetched response body — N entities from one
|
|
933
|
+
* call, where run() returns one. Same envelope, plus whichever of the URL and
|
|
934
|
+
* the params the caller reached the endpoint with.
|
|
935
|
+
*
|
|
936
|
+
* @param {string} id
|
|
937
|
+
* @param {string} body
|
|
938
|
+
* @param {{ url?: string, params?: object }} [context]
|
|
939
|
+
* @returns {{ template: string, template_name: string, url?: string, params?: object,
|
|
940
|
+
* data: { items: object[], count: number }, extractedAt: string }}
|
|
941
|
+
*/
|
|
942
|
+
async runList(id, body, { url, params } = {}) {
|
|
943
|
+
const template = this.get(id);
|
|
944
|
+
if (!template) {
|
|
945
|
+
throw new Error(`Unknown template: "${id}". Available: ${this._order.map(t => t.id).join(', ')}`);
|
|
946
|
+
}
|
|
947
|
+
if (!template.extractList) {
|
|
948
|
+
const lists = this._order.filter(t => t.extractList).map(t => t.id).join(', ');
|
|
949
|
+
throw new Error(
|
|
950
|
+
`Template "${id}" returns a single entity, not a list. Use run() instead. ` +
|
|
951
|
+
`List connectors: ${lists || 'none registered'}.`
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
return {
|
|
956
|
+
template: id,
|
|
957
|
+
template_name: template.name,
|
|
958
|
+
...(url !== undefined ? { url } : {}),
|
|
959
|
+
...(params !== undefined ? { params } : {}),
|
|
960
|
+
data: template.extractList(body, url),
|
|
961
|
+
extractedAt: new Date().toISOString()
|
|
962
|
+
};
|
|
963
|
+
}
|
|
700
964
|
}
|
|
701
965
|
|
|
702
966
|
export default TemplateRegistry;
|