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/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
- const variants = product.variants.map(v => ({
291
- id: v.id,
292
- title: v.title,
293
- price: money(v.price),
294
- compare_at_price: compareAtPrice(v.compare_at_price),
295
- sku: v.sku || null,
296
- available: variantAvailable(v),
297
- inventory_quantity: typeof v.inventory_quantity === 'number' ? v.inventory_quantity : null,
298
- options: [v.option1, v.option2, v.option3].filter(Boolean)
299
- }));
300
-
301
- const prices = variants.map(v => Number.parseFloat(v.price)).filter(Number.isFinite);
302
- const first = variants[0] || {};
303
- const availability = variants.map(v => v.available);
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
- title: product.title || null,
307
- vendor: product.vendor || null,
308
- product_type: product.product_type || null,
309
- handle: product.handle || null,
310
- product_id: product.id ?? null,
311
-
312
- // Headline price is the first variant's, matching what the product page
313
- // shows before a selection is made.
314
- price: first.price ?? null,
315
- compare_at_price: first.compare_at_price ?? null,
316
- // A compare-at price above the price is what renders as a sale badge.
317
- on_sale: first.compare_at_price !== null && first.compare_at_price !== undefined
318
- ? Number.parseFloat(first.compare_at_price) > Number.parseFloat(first.price)
319
- : false,
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
- constructor() {
650
- this._templates = new Map(TEMPLATES.map(t => [t.id, t]));
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 template IDs and names.
655
- * @returns {{ id: string, name: string, description: string }[]}
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 TEMPLATES.map(({ id, name, description, targetPattern }) => ({
659
- id, name, description,
660
- targetPattern: targetPattern.toString()
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: ${TEMPLATES.map(t => t.id).join(', ')}`);
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;