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/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. */
@@ -220,6 +313,18 @@ function githubLicense(license) {
220
313
  return (spdx && spdx !== 'NOASSERTION' ? spdx : license.name) || null;
221
314
  }
222
315
 
316
+ /**
317
+ * og:description is never just the About text. GitHub appends "Contribute to
318
+ * owner/repo development by creating an account on GitHub." to it, and that
319
+ * sentence is the *whole* value on a repo with no description at all. Strip
320
+ * it; whatever remains is the description, and nothing remaining means the
321
+ * repo has none.
322
+ */
323
+ function githubOgDescription($) {
324
+ const og = attr($, 'meta[property="og:description"]', 'content') || '';
325
+ return tidy(og.replace(/\s*Contribute to \S+ development by creating an account on GitHub\.?/, ''));
326
+ }
327
+
223
328
  /**
224
329
  * Repo tab counters stamp the exact number in title= ("5,102") and an
225
330
  * abbreviated text ("5.1k"); a counter with nothing to show stamps
@@ -275,51 +380,126 @@ export const TEMPLATES = [
275
380
  );
276
381
  }
277
382
 
278
- const variants = product.variants.map(v => ({
279
- id: v.id,
280
- title: v.title,
281
- price: money(v.price),
282
- compare_at_price: compareAtPrice(v.compare_at_price),
283
- sku: v.sku || null,
284
- available: variantAvailable(v),
285
- inventory_quantity: typeof v.inventory_quantity === 'number' ? v.inventory_quantity : null,
286
- options: [v.option1, v.option2, v.option3].filter(Boolean)
287
- }));
288
-
289
- const prices = variants.map(v => Number.parseFloat(v.price)).filter(Number.isFinite);
290
- const first = variants[0] || {};
291
- 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;
292
487
 
293
488
  return {
294
- title: product.title || null,
295
- vendor: product.vendor || null,
296
- product_type: product.product_type || null,
297
- handle: product.handle || null,
298
- product_id: product.id ?? null,
299
-
300
- // Headline price is the first variant's, matching what the product page
301
- // shows before a selection is made.
302
- price: first.price ?? null,
303
- compare_at_price: first.compare_at_price ?? null,
304
- // A compare-at price above the price is what renders as a sale badge.
305
- on_sale: first.compare_at_price !== null && first.compare_at_price !== undefined
306
- ? Number.parseFloat(first.compare_at_price) > Number.parseFloat(first.price)
307
- : false,
308
- currency: product.variants[0]?.price_currency || null,
309
- price_min: prices.length ? String(Math.min(...prices).toFixed(2)) : null,
310
- price_max: prices.length ? String(Math.max(...prices).toFixed(2)) : null,
311
-
312
- available: availability.some(a => a === true) ? true
313
- : availability.every(a => a === false) ? false
314
- : null,
315
- variants,
316
- options: (product.options || []).map(o => o.name),
317
-
318
- description: htmlToText(product.body_html),
319
- tags: normalizeTags(product.tags),
320
- images: (product.images || []).map(i => i.src),
321
- published_at: product.published_at || null,
322
- 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
323
503
  };
324
504
  }
325
505
  },
@@ -391,7 +571,12 @@ export const TEMPLATES = [
391
571
  const about = githubSidebarAbout($);
392
572
  return {
393
573
  name: text($, 'strong[itemprop="name"] a') || text($, '.repository-content h1'),
394
- description: attr($, 'meta[property="og:description"]', 'content') || text($, 'p.f4.my-3'),
574
+ // Like homepage: when the sidebar payload is present it is
575
+ // authoritative, so a repo that set no description reports null
576
+ // rather than GitHub's "Contribute to ..." boilerplate.
577
+ description: about
578
+ ? tidy(about.description)
579
+ : tidy(text($, 'p.f4.my-3')) || githubOgDescription($),
395
580
  stars: text($, '#repo-stars-counter-star') || text($, '[aria-label*="stargazers"]'),
396
581
  forks: text($, '#repo-network-counter') || text($, '[aria-label*="forks"]'),
397
582
  // React (logged-out) layout has no watchers aria-label; the count is
@@ -624,26 +809,78 @@ export const TEMPLATES = [
624
809
  };
625
810
  }
626
811
  }
812
+ ,
813
+
814
+ ...ATS_TEMPLATES,
815
+ ...GOV_TEMPLATES
627
816
  ];
628
817
 
629
818
  // ── Registry ─────────────────────────────────────────────────────────────────
630
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
+
631
836
  export class TemplateRegistry {
632
- constructor() {
633
- 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]));
634
844
  }
635
845
 
636
846
  /**
637
- * List all registered template IDs and names.
638
- * @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.
639
849
  */
640
850
  list() {
641
- return TEMPLATES.map(({ id, name, description, targetPattern }) => ({
642
- id, name, description,
643
- 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 } : {})
644
861
  }));
645
862
  }
646
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
+
647
884
  /**
648
885
  * Look up a template by ID.
649
886
  * @param {string} id
@@ -664,7 +901,17 @@ export class TemplateRegistry {
664
901
  async run(id, body, url, fetchedUrl = url) {
665
902
  const template = this.get(id);
666
903
  if (!template) {
667
- 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
+ );
668
915
  }
669
916
 
670
917
  const data = template.extractRaw
@@ -680,6 +927,40 @@ export class TemplateRegistry {
680
927
  extractedAt: new Date().toISOString()
681
928
  };
682
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
+ }
683
964
  }
684
965
 
685
966
  export default TemplateRegistry;