crawlforge-extractors 1.14.0 → 1.16.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 CHANGED
@@ -202,7 +202,7 @@ URL is reported in `reason` as a retired handle.
202
202
  ### Recognising a blocked page
203
203
 
204
204
  `documentVerdict` says what a fetched document is: the page, a bot-wall
205
- interstitial (Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel, AWS WAF, F5), an
205
+ interstitial (Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel, AWS WAF, F5, Fastly), an
206
206
  HTTP error page, an empty shell, a short error-titled placeholder, or a
207
207
  client-rendered app's own short error fallback under a normal title (quora.com's
208
208
  "Something went wrong. Wait a moment and try again."). A wall
@@ -291,6 +291,11 @@ deduplicated on the URL without fragment or trailing slash; a
291
291
 
292
292
  **Government APIs** (`src/connectors/gov.js`). `nhtsa-vin` · `npi-provider`
293
293
 
294
+ A template that can run from params instead of a URL lists them in `params`
295
+ (`[{ name, required, description }]`, also in `registry.list()`). A template
296
+ can add `warnings` to its result: `reddit-thread` warns when the URL names a
297
+ different subreddit from the post's.
298
+
294
299
  `shopify-collection` is a list connector: it reads a store's own
295
300
  `/collections/<handle>/products.json` and returns every product in the
296
301
  collection with the same authoritative price, compare-at price and stock that
package/index.d.ts CHANGED
@@ -62,6 +62,20 @@ export interface ScrapeTemplate {
62
62
  * host rate limiter's job.
63
63
  */
64
64
  crawlDelaySeconds?: number;
65
+ /** What listUrl takes. Declared by every template that defines listUrl. */
66
+ params?: TemplateParam[];
67
+ /**
68
+ * Notes about a request that still produced a correct record (reddit-thread:
69
+ * the URL named the wrong subreddit). run() returns them as `warnings`.
70
+ */
71
+ warnings?: (data: Record<string, unknown>, url: string) => string[];
72
+ }
73
+
74
+ /** One parameter a list connector's listUrl accepts. */
75
+ export interface TemplateParam {
76
+ name: string;
77
+ required: boolean;
78
+ description: string;
65
79
  }
66
80
 
67
81
  export interface TemplateSummary {
@@ -76,6 +90,8 @@ export interface TemplateSummary {
76
90
  requires_api_key?: true;
77
91
  /** Present only when the template sets credentialRef. */
78
92
  credential_ref?: string;
93
+ /** Present on every template that can be driven by params. */
94
+ params?: TemplateParam[];
79
95
  }
80
96
 
81
97
  export interface TemplateResult {
@@ -86,6 +102,8 @@ export interface TemplateResult {
86
102
  fetchedUrl?: string;
87
103
  data: Record<string, unknown>;
88
104
  extractedAt: string;
105
+ /** Present only when the template's warnings hook reported something. */
106
+ warnings?: string[];
89
107
  }
90
108
 
91
109
  export interface TemplateListResult {
@@ -308,7 +326,7 @@ export default TemplateRegistry;
308
326
 
309
327
  /** The bot-defence vendor whose interstitial a document is, and what gave it away. */
310
328
  export interface ChallengeVerdict {
311
- vendor: 'cloudflare' | 'amazon' | 'datadome' | 'perimeterx' | 'akamai' | 'vercel' | 'aws-waf' | 'f5';
329
+ vendor: 'cloudflare' | 'amazon' | 'datadome' | 'perimeterx' | 'akamai' | 'vercel' | 'aws-waf' | 'f5' | 'fastly';
312
330
  evidence: string;
313
331
  }
314
332
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, embedded-state extraction, and query-scoped highlights. One implementation, so the two surfaces cannot drift apart.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * blockedPage.js — decide whether a fetched document is the page or a wall.
3
3
  *
4
- * Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel, AWS WAF and F5 answer
4
+ * Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel, AWS WAF, F5 and Fastly answer
5
5
  * a blocked request with a page of their own, often under HTTP 200: a title, some prose
6
6
  * and a challenge script. Reported as a successful scrape, that page hides
7
7
  * the block — producthunt.com came back "success:true, title: Just a
@@ -80,6 +80,19 @@ const CHALLENGES = [
80
80
  title: /^request rejected$/i,
81
81
  markers: /the requested url was rejected\.?\s*please consult with your administrator/i,
82
82
  evidence: 'an F5 "Request Rejected" blocking page'
83
+ },
84
+ {
85
+ // Fastly's client challenge: HTTP 200, the title "Client Challenge", a
86
+ // <noscript> asking for JavaScript and a loader for
87
+ // /_fs-ch-<token>/script.js, every asset under that same prefix
88
+ // (lemonde.fr to a plain fetch, 3 KB, `via: 1.1 varnish` and
89
+ // `x-served-by: cache-…`, 2026-10-03). Its only text outside <noscript>
90
+ // is a hidden "A required part of this site couldn't load" notice, which
91
+ // is why it read as a page rather than an empty shell.
92
+ vendor: 'fastly',
93
+ title: /^client challenge$/i,
94
+ markers: /\/_fs-ch-[\w-]+\//,
95
+ evidence: 'a Fastly client challenge script'
83
96
  }
84
97
  ];
85
98
 
@@ -232,6 +232,11 @@ export const ATS_TEMPLATES = [
232
232
  'payload: Stripe\'s 571-job board is 349 KB without them and 4.2 MB with them.',
233
233
  targetPattern: /(boards|job-boards|boards-api)\.greenhouse\.io\//i,
234
234
 
235
+ params: [
236
+ { name: 'company', required: true, description: 'Board token, the path segment in https://job-boards.greenhouse.io/<token>.' },
237
+ { name: 'content', required: false, description: 'true adds each job\'s description (much larger response).' }
238
+ ],
239
+
235
240
  /**
236
241
  * `company` is Greenhouse's board token — the path segment in
237
242
  * https://job-boards.greenhouse.io/<token>, not the company's display name.
@@ -322,6 +327,12 @@ export const ATS_TEMPLATES = [
322
327
  */
323
328
  crawlDelaySeconds: 1,
324
329
 
330
+ params: [
331
+ { name: 'company', required: true, description: 'The path segment in https://jobs.lever.co/<company>.' },
332
+ { name: 'skip', required: false, description: 'Postings to skip, for paging.' },
333
+ { name: 'limit', required: false, description: 'Postings to return, for paging.' }
334
+ ],
335
+
325
336
  /** `company` is the path segment in https://jobs.lever.co/<company>. */
326
337
  listUrl(params = {}) {
327
338
  const company = requireParam(
@@ -404,6 +415,11 @@ export const ATS_TEMPLATES = [
404
415
  'a fraction of that without.',
405
416
  targetPattern: /(jobs|api)\.ashbyhq\.com\//i,
406
417
 
418
+ params: [
419
+ { name: 'company', required: true, description: 'Jobs page name, the segment in https://jobs.ashbyhq.com/<name>.' },
420
+ { name: 'descriptions', required: false, description: 'true keeps each job\'s plain-text description.' }
421
+ ],
422
+
407
423
  /** `company` is Ashby's jobs page name — the segment in https://jobs.ashbyhq.com/<name>. */
408
424
  listUrl(params = {}) {
409
425
  const company = requireParam(
@@ -495,6 +511,11 @@ export const ATS_TEMPLATES = [
495
511
  'documented ?details=true parameter.',
496
512
  targetPattern: /(apply\.workable\.com|www\.workable\.com\/api\/accounts|[\w-]+\.workable\.com\/(jobs|spi))/i,
497
513
 
514
+ params: [
515
+ { name: 'company', required: true, description: 'Account subdomain, the path segment in https://apply.workable.com/<subdomain>.' },
516
+ { name: 'details', required: false, description: 'true adds each job\'s description.' }
517
+ ],
518
+
498
519
  /** `company` is the account subdomain — the first part of the signed-in Workable URL. */
499
520
  listUrl(params = {}) {
500
521
  const company = requireParam(
@@ -580,6 +601,10 @@ export const ATS_TEMPLATES = [
580
601
  'plain-text description for every open role in one request.',
581
602
  targetPattern: /\.recruitee\.com\//i,
582
603
 
604
+ params: [
605
+ { name: 'company', required: true, description: 'The subdomain in https://<company>.recruitee.com.' }
606
+ ],
607
+
583
608
  /** `company` is the Recruitee subdomain in https://<company>.recruitee.com. */
584
609
  listUrl(params = {}) {
585
610
  const company = requireParam(
@@ -672,6 +697,12 @@ export const ATS_TEMPLATES = [
672
697
  // which robots disallows anyway) still does not match.
673
698
  targetPattern: /teamtailor\.com\/?(?:jobs(\.rss)?\/?)?(\?|#|$)/i,
674
699
 
700
+ params: [
701
+ { name: 'company', required: true, description: 'The subdomain in https://<company>.teamtailor.com.' },
702
+ { name: 'per_page', required: false, description: 'Jobs per feed page (feed default 100).' },
703
+ { name: 'offset', required: false, description: 'Jobs to skip, for paging.' }
704
+ ],
705
+
675
706
  /** `company` is the subdomain in https://<company>.teamtailor.com. */
676
707
  listUrl(params = {}) {
677
708
  const company = requireParam(
@@ -199,6 +199,11 @@ export const GOV_TEMPLATES = [
199
199
  'unknown positions), and vPIC\'s own error codes are reported rather than swallowed.',
200
200
  targetPattern: /vpic\.nhtsa\.dot\.gov\/api\/vehicles\/DecodeVin/i,
201
201
 
202
+ params: [
203
+ { name: 'vin', required: true, description: 'The VIN to decode; "*" marks an unknown position, as in 5UXWX7C5*BA.' },
204
+ { name: 'modelYear', required: false, description: 'Model year, which vPIC says improves decode accuracy.' }
205
+ ],
206
+
202
207
  /**
203
208
  * @param {{ vin: string, modelYear?: string|number }} params
204
209
  */
@@ -342,6 +347,17 @@ export const GOV_TEMPLATES = [
342
347
  'NPI and joins nothing to it.',
343
348
  targetPattern: /npiregistry\.cms\.hhs\.gov\/api/i,
344
349
 
350
+ // At least one search criterion is required; none of them is on its own.
351
+ params: [
352
+ ...NPI_SEARCH_FIELDS.map(name => ({
353
+ name,
354
+ required: false,
355
+ description: 'Registry search criterion, in its own spelling. Pass at least one.'
356
+ })),
357
+ { name: 'limit', required: false, description: `Records to return, 1-${NPI_MAX_LIMIT} (default ${NPI_DEFAULT_LIMIT}).` },
358
+ { name: 'skip', required: false, description: `Records to skip, 0-${NPI_MAX_SKIP}.` }
359
+ ],
360
+
345
361
  /**
346
362
  * @param {Record<string, string|number>} params — the registry's own search fields
347
363
  */
package/src/pageText.js CHANGED
@@ -22,8 +22,10 @@ const BLOCK_SELECTOR =
22
22
  const SENTINEL = '\uE000';
23
23
 
24
24
  /**
25
- * Flatten elements to text, one line per block element. Table cells on a row
26
- * are joined by a space. Works on a detached clone, so the caller's tree is
25
+ * Flatten elements to text, one line per block element: a block starts and
26
+ * ends a line, so inline text just before one is not welded to it (Readability
27
+ * turns "July 2023<br><br>If you…" into "July 2023<p>If you…</p>", which read
28
+ * "July 2023If you…", R24 3.1). Table cells on a row are joined by a space. Works on a detached clone, so the caller's tree is
27
29
  * untouched. Callers remove `<script>`/`<style>` first: their text is read
28
30
  * like any other.
29
31
  *
@@ -36,7 +38,7 @@ export function flattenText($, root = $('body')) {
36
38
  const $root = root.clone();
37
39
  $root.find('br').replaceWith(SENTINEL);
38
40
  $root.find('td, th').after(' ');
39
- $root.find(BLOCK_SELECTOR).after(SENTINEL);
41
+ $root.find(BLOCK_SELECTOR).before(SENTINEL).after(SENTINEL);
40
42
  return $root
41
43
  .toArray()
42
44
  .map((el) => $(el).text())
package/src/templates.js CHANGED
@@ -32,6 +32,12 @@
32
32
  * Defining extractList is the only signal that a template is a list connector;
33
33
  * there is no `kind` field. A list connector may also define
34
34
  * resolveUrl/targetPattern, so a caller can pass a URL instead of params.
35
+ * Every template with a listUrl declares what it takes, which list() publishes:
36
+ * params — [{ name, required, description }]
37
+ *
38
+ * One more optional hook reports what the caller should know about a request
39
+ * that still produced a correct record; run() returns it as `warnings`:
40
+ * warnings(data,url) — string[]
35
41
  *
36
42
  * A connector against a key-based API declares it:
37
43
  * requiresApiKey: true
@@ -519,6 +525,37 @@ function githubCounter($, sel) {
519
525
  return /\d/.test(exact || '') ? exact : text($, sel);
520
526
  }
521
527
 
528
+ /**
529
+ * A count as an integer, or null. The sidebar payload carries exact numbers
530
+ * (stargazerCount: 32407) and a counter's title= the same number with commas
531
+ * ("32,407"); the counter's visible text is rounded ("32.4k") and reads as
532
+ * null here rather than as a number it is not.
533
+ */
534
+ function githubCount(value) {
535
+ if (typeof value === 'number') return Number.isInteger(value) ? value : null;
536
+ const digits = typeof value === 'string' ? value.replace(/,/g, '').trim() : '';
537
+ return /^\d+$/.test(digits) ? Number.parseInt(digits, 10) : null;
538
+ }
539
+
540
+ /** README excerpts are capped: the first paragraph of some READMEs is an essay. */
541
+ const README_SUMMARY_MAX = 500;
542
+
543
+ /**
544
+ * The README's first paragraph with text in it. The rendered README is in the
545
+ * served page (article.markdown-body); a leading paragraph of badges is all
546
+ * <img> and has no text, so it is skipped.
547
+ */
548
+ function githubReadmeSummary($) {
549
+ for (const el of $('article.markdown-body p').toArray()) {
550
+ const paragraph = $(el).text().replace(/\s+/g, ' ').trim();
551
+ if (!paragraph) continue;
552
+ return paragraph.length > README_SUMMARY_MAX
553
+ ? `${paragraph.slice(0, README_SUMMARY_MAX - 1).trimEnd()}…`
554
+ : paragraph;
555
+ }
556
+ return null;
557
+ }
558
+
522
559
  // ── Template definitions ─────────────────────────────────────────────────────
523
560
 
524
561
  export const TEMPLATES = [
@@ -601,6 +638,13 @@ export const TEMPLATES = [
601
638
  return parsed.toString();
602
639
  },
603
640
 
641
+ params: [
642
+ { name: 'store', required: true, description: 'Storefront domain, e.g. "www.allbirds.com".' },
643
+ { name: 'collection', required: true, description: 'Collection handle, e.g. "mens" from /collections/mens.' },
644
+ { name: 'limit', required: false, description: 'Products per page, 1-250 (Shopify default 30).' },
645
+ { name: 'page', required: false, description: 'Page number, 1 or more.' }
646
+ ],
647
+
604
648
  /**
605
649
  * Build the listing URL from params instead of a URL.
606
650
  * @param {{ store: string, collection: string, limit?: number, page?: number }} params
@@ -749,8 +793,24 @@ export const TEMPLATES = [
749
793
  {
750
794
  id: 'github-repo',
751
795
  name: 'GitHub Repository',
752
- description: 'Scrape a GitHub repository page for stars, forks, description, language, topics, and README summary.',
753
- targetPattern: /github\.com\/[^/]+\/[^/]+\/?$/i,
796
+ description:
797
+ 'Scrape a GitHub repository page for description, exact star, fork, watcher and open-issue ' +
798
+ 'counts, topics, licence, homepage and a README summary (the README\'s first paragraph). ' +
799
+ 'A /tree/<branch>/… link reads the repository\'s overview page. Language and last-push ' +
800
+ 'date are null on the page GitHub serves logged out — its browser fetches them after load.',
801
+ targetPattern: /github\.com\/[^/]+\/[^/]+(?:\/tree\/.+)?\/?$/i,
802
+
803
+ /**
804
+ * A /tree/<branch>/<path> link is a directory listing: no About sidebar,
805
+ * no README of the repository. The repo-level facts this template returns
806
+ * live on the overview page, so that is what gets fetched.
807
+ */
808
+ resolveUrl(url) {
809
+ const parsed = safeUrl(url);
810
+ const repo = parsed && /^\/([^/]+)\/([^/]+)\/tree\//.exec(parsed.pathname);
811
+ return repo ? `${parsed.origin}/${repo[1]}/${repo[2]}` : url;
812
+ },
813
+
754
814
  extract($) {
755
815
  const about = githubSidebarAbout($);
756
816
  return {
@@ -761,11 +821,13 @@ export const TEMPLATES = [
761
821
  description: about
762
822
  ? tidy(about.description)
763
823
  : tidy(text($, 'p.f4.my-3')) || githubOgDescription($),
764
- stars: text($, '#repo-stars-counter-star') || text($, '[aria-label*="stargazers"]'),
765
- forks: text($, '#repo-network-counter') || text($, '[aria-label*="forks"]'),
824
+ // Exact integers: the payload's counts first, then the counter's
825
+ // title=. The counter's text ("32.4k") is rounded and never used.
826
+ stars: githubCount(about?.stargazerCount) ?? githubCount(attr($, '#repo-stars-counter-star', 'title')),
827
+ forks: githubCount(about?.forksCount) ?? githubCount(attr($, '#repo-network-counter', 'title')),
766
828
  // React (logged-out) layout has no watchers aria-label; the count is
767
829
  // the <strong> right after the single octicon-eye.
768
- watchers: text($, '.octicon-eye + strong') || text($, '[aria-label*="watchers"]'),
830
+ watchers: githubCount(about?.watcherCount) ?? githubCount(text($, '.octicon-eye + strong')),
769
831
  // Language and the last-push date appear nowhere in the logged-out
770
832
  // page (verified 2026-08-26 across five User-Agents, embedded JSON
771
833
  // included): the client fetches them after load from header-gated
@@ -781,7 +843,8 @@ export const TEMPLATES = [
781
843
  // When the sidebar payload is present it is authoritative: an empty
782
844
  // website means "no homepage", not "go scrape some external link".
783
845
  homepage: safeHref(about ? about.website : attr($, 'a[href][rel="noopener noreferrer"]', 'href')),
784
- open_issues: githubCounter($, '#issues-repo-tab-count') || text($, '.Counter[aria-label*="issue"]')
846
+ open_issues: githubCount(githubCounter($, '#issues-repo-tab-count') || text($, '.Counter[aria-label*="issue"]')),
847
+ readme_summary: githubReadmeSummary($)
785
848
  };
786
849
  }
787
850
  },
@@ -790,7 +853,19 @@ export const TEMPLATES = [
790
853
  id: 'youtube-video',
791
854
  name: 'YouTube Video',
792
855
  description: 'Scrape a YouTube video page for title, channel, views, likes, publish date, and description.',
793
- targetPattern: /youtube\.com\/watch/i,
856
+ targetPattern: /youtube\.com\/watch|youtu\.be\/[\w-]+/i,
857
+
858
+ /**
859
+ * youtu.be/<id> is the share-button link. youtu.be only redirects, so the
860
+ * watch page for the same id is what gets fetched.
861
+ */
862
+ resolveUrl(url) {
863
+ const parsed = safeUrl(url);
864
+ if (!parsed || !/^(?:www\.)?youtu\.be$/i.test(parsed.hostname)) return url;
865
+ const id = parsed.pathname.split('/').filter(Boolean)[0];
866
+ return id ? `https://www.youtube.com/watch?v=${encodeURIComponent(id)}` : url;
867
+ },
868
+
794
869
  extract($) {
795
870
  return {
796
871
  title: attr($, 'meta[name="title"]', 'content') || attr($, 'meta[property="og:title"]', 'content'),
@@ -866,6 +941,21 @@ export const TEMPLATES = [
866
941
  removed: post.removed_by_category ?? post._meta?.removal_type ?? null,
867
942
  note: 'Read from the Arctic Shift archive, not reddit.com. Scores and comment counts of content less than ~36h old may read 0/1. For the comment tree call reddit_search with mode:"thread" and this id.'
868
943
  };
944
+ },
945
+
946
+ /**
947
+ * Only the post id reaches the archive, so a URL naming the wrong
948
+ * subreddit still returns the right post — ids are unique across Reddit,
949
+ * and reddit.com itself redirects such a URL to the real one. That is
950
+ * worth a warning, not a refusal: the caller gets the post it named.
951
+ */
952
+ warnings(data, url) {
953
+ const named = /reddit\.com\/r\/([^/?#]+)\/comments\//i.exec(url ?? '')?.[1];
954
+ if (!named || !data.subreddit || named.toLowerCase() === data.subreddit.toLowerCase()) return [];
955
+ return [
956
+ `The URL names r/${named}, but post ${data.id} is in r/${data.subreddit}. Reddit post ids ` +
957
+ 'are unique, so this is the post the id names; data.url is its real address.'
958
+ ];
869
959
  }
870
960
  },
871
961
 
@@ -1177,6 +1267,8 @@ export class TemplateRegistry {
1177
1267
  targetPattern: t.targetPattern ? t.targetPattern.toString() : null,
1178
1268
  // extractList is the only signal that a template returns N entities.
1179
1269
  mode: t.extractList ? 'list' : 'entity',
1270
+ // What listUrl takes, so a caller can drive a connector without a URL.
1271
+ ...(t.params ? { params: t.params } : {}),
1180
1272
  ...(t.requiresApiKey ? { requires_api_key: true } : {}),
1181
1273
  ...(t.credentialRef ? { credential_ref: t.credentialRef } : {})
1182
1274
  }));
@@ -1254,13 +1346,18 @@ export class TemplateRegistry {
1254
1346
  );
1255
1347
  }
1256
1348
 
1349
+ // Something about the request worth telling the caller that does not make
1350
+ // the record wrong (reddit-thread: the URL named the wrong subreddit).
1351
+ const warnings = template.warnings ? template.warnings(data, url) : [];
1352
+
1257
1353
  return {
1258
1354
  template: id,
1259
1355
  template_name: template.name,
1260
1356
  url,
1261
1357
  ...(fetchedUrl !== url ? { fetchedUrl } : {}),
1262
1358
  data,
1263
- extractedAt: new Date().toISOString()
1359
+ extractedAt: new Date().toISOString(),
1360
+ ...(warnings.length > 0 ? { warnings } : {})
1264
1361
  };
1265
1362
  }
1266
1363