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
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
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
|
|
638
|
-
*
|
|
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
|
|
642
|
-
id
|
|
643
|
-
|
|
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: ${
|
|
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;
|