crawlforge-extractors 1.5.2 → 1.6.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
@@ -40,6 +40,8 @@ const template = registry.get('shopify-product');
40
40
  const url = 'https://shop.example.com/products/some-handle';
41
41
  const fetchUrl = template.resolveUrl ? template.resolveUrl(url) : url;
42
42
 
43
+ // Apply your SSRF policy to fetchUrl (the RETURNED url) and to any redirect the
44
+ // fetch follows — not just to `url`. resolveUrl/listUrl may rewrite the target.
43
45
  const body = await (await fetch(fetchUrl)).text();
44
46
  const result = await registry.run('shopify-product', body, url, fetchUrl);
45
47
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, and embedded-state extraction. One implementation, so the two surfaces cannot drift apart.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/src/body.js CHANGED
@@ -70,9 +70,18 @@ export async function readBody(response, options = {}) {
70
70
  }
71
71
 
72
72
  // Only the byte-count guard needs a stream. Responses that are already
73
- // buffered (and test doubles) are read as-is so callers still get their text.
73
+ // buffered (and test doubles) have no reader to meter, but a server can omit
74
+ // or lie about Content-Length, so enforce the cap on the read result too.
74
75
  if (!response.body || typeof response.body.getReader !== 'function') {
75
- return response.text();
76
+ const text = await response.text();
77
+ const size = Buffer.byteLength(text, 'utf8');
78
+ if (size > maxBytes) {
79
+ throw new BodyTooLargeError(
80
+ `Response body too large: ${size} bytes exceeds limit of ${maxBytes} bytes`,
81
+ { limit: maxBytes, size }
82
+ );
83
+ }
84
+ return text;
76
85
  }
77
86
 
78
87
  const reader = response.body.getReader();
@@ -398,8 +398,10 @@ export const ATS_TEMPLATES = [
398
398
  name: 'Ashby Job Board',
399
399
  description:
400
400
  'Read a company\'s whole Ashby job board from the Public Job Posting API rather than the ' +
401
- 'rendered careers page: every listed job with its department, team, employment type, ' +
402
- 'workplace type and plain-text description in one request.',
401
+ 'rendered careers page: every listed job with its department, team, employment type and ' +
402
+ 'workplace type in one request. Descriptions are opt-in pass descriptions: true — ' +
403
+ 'because they are most of the payload: OpenAI\'s 767-job board is 5.9 MB with them and ' +
404
+ 'a fraction of that without.',
403
405
  targetPattern: /(jobs|api)\.ashbyhq\.com\//i,
404
406
 
405
407
  /** `company` is Ashby's jobs page name — the segment in https://jobs.ashbyhq.com/<name>. */
@@ -418,7 +420,7 @@ export const ATS_TEMPLATES = [
418
420
  return company ? ashbyUrl({ company }) : url;
419
421
  },
420
422
 
421
- extractList(body, url) {
423
+ extractList(body, url, params = {}) {
422
424
  const payload = parseJson(body, () =>
423
425
  notJson('Ashby job board', url, 'the Ashby Public Job Posting API')
424
426
  );
@@ -446,7 +448,11 @@ export const ATS_TEMPLATES = [
446
448
  remote: isRemote(j.workplaceType),
447
449
  published_at: isoDate(j.publishedAt),
448
450
  updated_at: null,
449
- description: str(j.descriptionPlain),
451
+ // The API has no query switch for this, so the trim happens here:
452
+ // descriptions are most of the payload (OpenAI's 767-job board is
453
+ // 5.9 MB with them, 2026-09-01), matching Greenhouse content:true
454
+ // and Workable details:true.
455
+ description: params?.descriptions === true ? str(j.descriptionPlain) : null,
450
456
  source: 'ashby-jobs',
451
457
  raw_extra: {
452
458
  workplace_type: str(j.workplaceType),
@@ -457,7 +463,15 @@ export const ATS_TEMPLATES = [
457
463
  }
458
464
  }));
459
465
 
460
- return listResult(items, { api_version: str(payload.apiVersion) });
466
+ // The API carries no organization name anywhere in its payload; the
467
+ // board slug — from the caller's params, or the path segment of the
468
+ // API URL — is the one identity the response itself confirms (every
469
+ // jobUrl embeds it), and beats reporting null.
470
+ const company =
471
+ str(params?.company) ||
472
+ (url ? str(new URL(url).pathname.split('/').filter(Boolean).pop()) : null);
473
+
474
+ return listResult(items, { company, api_version: str(payload.apiVersion) });
461
475
  }
462
476
  },
463
477
 
@@ -35,6 +35,14 @@ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
35
35
  // self.__next_f.push([1,"<chunk>"]) — Next.js App Router RSC flight chunks.
36
36
  const NEXT_F_PUSH_RE = /self\.__next_f\.push\(\s*\[\s*1\s*,\s*/g;
37
37
 
38
+ // (window[Symbol.for("ApolloSSRDataTransport")] ??= []).push({...}) — Apollo
39
+ // Client's streaming-SSR rehydration payload. On an App Router page the RSC
40
+ // flight stream can be a near-empty shell while the page's real data — the
41
+ // GraphQL results its UI renders from — rides in these pushes instead
42
+ // (producthunt.com product pages, observed 2026-09-01).
43
+ const APOLLO_TRANSPORT_RE =
44
+ /Symbol\.for\(\s*["']ApolloSSRDataTransport["']\s*\)\s*\]\s*\?\?=\s*\[\s*\]\s*\)\s*\.push\(\s*/g;
45
+
38
46
  /**
39
47
  * Read an HTML attribute out of a raw tag's attribute string.
40
48
  * @param {string} attrs
@@ -191,6 +199,35 @@ function readFlightStream(html) {
191
199
  return { chunks: parts.length, stream: parts.join('') };
192
200
  }
193
201
 
202
+ /**
203
+ * Collect every ApolloSSRDataTransport push in document order and parse it.
204
+ *
205
+ * The pushed literal is JSON except for bare `undefined` values, which Apollo
206
+ * emits for fields a query is still streaming (`"data":undefined`). Those are
207
+ * healed to null before parsing: the lookbehind/lookahead pins `undefined` to
208
+ * value position, so the word inside a string literal is never touched. A push
209
+ * that still fails to parse is skipped rather than failing the page.
210
+ *
211
+ * @param {string} html raw HTML
212
+ * @returns {object[]} parsed push arguments, in document order
213
+ */
214
+ export function extractApolloTransport(html) {
215
+ const pushes = [];
216
+ APOLLO_TRANSPORT_RE.lastIndex = 0;
217
+ let match;
218
+ while ((match = APOLLO_TRANSPORT_RE.exec(html)) !== null) {
219
+ const literal = readBracketedLiteral(html, APOLLO_TRANSPORT_RE.lastIndex);
220
+ if (!literal) continue;
221
+ APOLLO_TRANSPORT_RE.lastIndex += literal.length;
222
+ try {
223
+ pushes.push(JSON.parse(literal.replace(/(?<=[:,[])\s*undefined\s*(?=[,}\]])/g, 'null')));
224
+ } catch {
225
+ // Not JSON we can heal — skip this push, keep the rest.
226
+ }
227
+ }
228
+ return pushes;
229
+ }
230
+
194
231
  const serializedBytes = (value) => Buffer.byteLength(JSON.stringify(value) ?? '');
195
232
 
196
233
  /**
@@ -256,6 +293,17 @@ export function extractEmbeddedState(rawHtml) {
256
293
  });
257
294
  }
258
295
 
296
+ const apolloPushes = extractApolloTransport(html);
297
+ if (apolloPushes.length > 0) {
298
+ data.apollo_ssr_transport = apolloPushes;
299
+ found.push({
300
+ name: 'apollo_ssr_transport',
301
+ variable: 'window[Symbol.for("ApolloSSRDataTransport")]',
302
+ bytes: serializedBytes(apolloPushes),
303
+ note: `${apolloPushes.length} streaming-SSR push(es), in document order`
304
+ });
305
+ }
306
+
259
307
  for (const { name, variable } of STATE_VARIABLES) {
260
308
  const assignment = html.match(
261
309
  new RegExp(`(?:window|self|globalThis)?\\.?\\b${variable}\\s*=\\s*`)
package/src/jsonPath.js CHANGED
@@ -66,7 +66,7 @@ export function selectJsonPath(root, path) {
66
66
  for (const segment of segments) {
67
67
  const container = current !== null && typeof current === 'object';
68
68
  const key = Array.isArray(current) ? Number(segment) : segment;
69
- if (!container || !(key in current)) {
69
+ if (!container || !Object.hasOwn(current, key)) {
70
70
  const at = walked.length === 0 ? 'the result' : `"${walked.join('.')}"`;
71
71
  throw new Error(
72
72
  `Path "${path}" not found: ${at} has no "${segment}" (${describeOptions(current)})`
package/src/templates.js CHANGED
@@ -14,7 +14,9 @@
14
14
  *
15
15
  * Templates do NOT make network calls. The caller fetches the page and passes
16
16
  * the body in; that keeps SSRF policy, timeouts and billing with the surface
17
- * that owns them.
17
+ * that owns them. resolveUrl/listUrl rewrite or build the target, so the caller
18
+ * must apply its SSRF policy to the URL they RETURN (and to every redirect the
19
+ * fetch follows), not just to the URL the caller started with.
18
20
  *
19
21
  * Two optional hooks let a template read a machine-readable endpoint instead of
20
22
  * scraping the rendered page, without taking the fetch into its own hands:
@@ -45,6 +47,7 @@ import { load } from 'cheerio';
45
47
  // have made one file the whole package.
46
48
  import { ATS_TEMPLATES } from './connectors/ats.js';
47
49
  import { GOV_TEMPLATES } from './connectors/gov.js';
50
+ import { extractApolloTransport } from './embeddedState.js';
48
51
  import { safeHref } from './urls.js';
49
52
 
50
53
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -541,6 +544,16 @@ export const TEMPLATES = [
541
544
  description: 'Scrape an Amazon product page for title, price, rating, reviews, ASIN, and description.',
542
545
  targetPattern: /amazon\.(com|co\.uk|de|fr|jp|ca|com\.au)/i,
543
546
  extract($) {
547
+ // Amazon serves its robot check as HTTP 200: a "Continue shopping" page
548
+ // whose only form posts to /errors/validateCaptcha. Every selector below
549
+ // misses on it, which used to come back as a silent all-null record
550
+ // (amazon.es, observed 2026-09-01) — name the block instead.
551
+ if ($('form[action*="validateCaptcha"]').length > 0) {
552
+ throw new Error(
553
+ 'Amazon answered with a captcha interstitial (an HTTP 200 robot check), not the product page. ' +
554
+ 'The product data is not in this response — retry later or from a different IP.'
555
+ );
556
+ }
544
557
  const bullets = $('#feature-bullets ul li span.a-list-item')
545
558
  .map((_, el) => tidy($(el).text()))
546
559
  .get()
@@ -732,17 +745,50 @@ export const TEMPLATES = [
732
745
  {
733
746
  id: 'producthunt-launch',
734
747
  name: 'Product Hunt Launch',
735
- description: 'Scrape a Product Hunt product page for name, tagline, vote count, topics, and maker details.',
736
- targetPattern: /producthunt\.com\/posts\//i,
737
- extract($) {
748
+ description:
749
+ 'Scrape a Product Hunt product page for name, tagline, description, categories, website, ' +
750
+ 'and follower/review counts. Product Hunt folded /posts/* launch pages into /products/* ' +
751
+ 'product hubs, which carry no product-level vote count — followers and reviews are the ' +
752
+ "page's engagement numbers now.",
753
+ targetPattern: /producthunt\.com\/(posts|products)\//i,
754
+ extractRaw(body, url) {
755
+ // The RSC flight stream on these pages is a near-empty shell; the data
756
+ // the UI renders from — the GraphQL Product record — rides in Apollo's
757
+ // streaming-SSR transport pushes instead.
758
+ const products = [];
759
+ const walk = (node) => {
760
+ if (!node || typeof node !== 'object') return;
761
+ if (node.__typename === 'Product') products.push(node);
762
+ for (const value of Object.values(node)) walk(value);
763
+ };
764
+ for (const push of extractApolloTransport(body)) walk(push);
765
+ // The transport carries several Product objects (latestLaunch.product,
766
+ // forum subjects); the page's own is the one with the page-level fields.
767
+ const product =
768
+ products.find(p => 'followersCount' in p) || products.find(p => 'websiteUrl' in p) || null;
769
+
770
+ const $ = load(body);
771
+ const metaName = attr($, 'meta[property="og:title"]', 'content');
772
+ const categories = Array.isArray(product?.categories)
773
+ ? product.categories.map(c => c?.name).filter(Boolean)
774
+ : null;
775
+ // The DOM fallback's href carries PH's ?ref=producthunt tracking param.
776
+ const domWebsite = (attr($, 'a[data-test="visit-website-button"]', 'href') || '')
777
+ .replace(/([?&])ref=producthunt(?=&|$)/, '$1')
778
+ .replace(/[?&]$/, '') || null;
779
+
738
780
  return {
739
- name: attr($, 'meta[property="og:title"]', 'content'),
740
- tagline: attr($, 'meta[property="og:description"]', 'content'),
781
+ name: product?.name || (metaName ? metaName.replace(/\s*\|\s*Product Hunt\s*$/i, '') : null),
782
+ tagline: product?.tagline || attr($, 'meta[property="og:description"]', 'content'),
783
+ description: product?.description ?? null,
741
784
  image: attr($, 'meta[property="og:image"]', 'content'),
742
- url: safeHref(attr($, 'meta[property="og:url"]', 'content')),
743
- votes: text($, '[data-test="vote-button"] span') || text($, 'button[data-vote-button]'),
744
- topics: list($, 'a[href*="/topics/"]'),
745
- website: safeHref(attr($, 'a[data-test="product-link"]', 'href') || attr($, 'a[href][rel="noopener"][target="_blank"]', 'href'))
785
+ url: safeHref(attr($, 'meta[property="og:url"]', 'content')) || url,
786
+ website: safeHref(product?.websiteUrl || domWebsite),
787
+ // null = the data layer was missing, [] = present with no categories.
788
+ topics: categories,
789
+ followers: product?.followersCount ?? null,
790
+ reviews_count: product?.reviewsCount ?? null,
791
+ reviews_rating: product?.reviewsRating ?? null
746
792
  };
747
793
  }
748
794
  },
@@ -1028,6 +1074,21 @@ export class TemplateRegistry {
1028
1074
  ? template.extractRaw(body, url)
1029
1075
  : template.extract(load(body));
1030
1076
 
1077
+ // A record with literally every field empty is not a page with no data —
1078
+ // it is a page the template's selectors all missed: an interstitial
1079
+ // (captcha, consent wall, bot check) served as HTTP 200, or a layout
1080
+ // change. Reporting it as success is how amazon.es's captcha page came
1081
+ // back as a clean all-null product (2026-09-01). Fail loudly instead.
1082
+ const values = data && typeof data === 'object' && !Array.isArray(data) ? Object.values(data) : [];
1083
+ const isEmpty = (v) => v == null || v === '' || (Array.isArray(v) && v.length === 0);
1084
+ if (values.length > 0 && values.every(isEmpty)) {
1085
+ throw new Error(
1086
+ `Template "${id}" matched the page but extracted no data — every field came back empty. ` +
1087
+ 'The server likely answered with an interstitial (captcha, consent or bot wall) or the site changed ' +
1088
+ 'its layout. This is an extraction failure, not a page with nothing on it.'
1089
+ );
1090
+ }
1091
+
1031
1092
  return {
1032
1093
  template: id,
1033
1094
  template_name: template.name,
@@ -1067,7 +1128,10 @@ export class TemplateRegistry {
1067
1128
  template_name: template.name,
1068
1129
  ...(url !== undefined ? { url } : {}),
1069
1130
  ...(params !== undefined ? { params } : {}),
1070
- data: template.extractList(body, url),
1131
+ // params ride along for connectors whose upstream API has no query
1132
+ // switch for an option (Ashby's descriptions opt-in trims at extract
1133
+ // time; Greenhouse/Workable put theirs in the URL instead).
1134
+ data: template.extractList(body, url, params),
1071
1135
  extractedAt: new Date().toISOString()
1072
1136
  };
1073
1137
  }