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 +2 -0
- package/package.json +1 -1
- package/src/body.js +11 -2
- package/src/connectors/ats.js +19 -5
- package/src/embeddedState.js +48 -0
- package/src/jsonPath.js +1 -1
- package/src/templates.js +75 -11
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.
|
|
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)
|
|
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
|
-
|
|
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();
|
package/src/connectors/ats.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/embeddedState.js
CHANGED
|
@@ -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
|
|
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:
|
|
736
|
-
|
|
737
|
-
|
|
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:
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
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
|
}
|