crawlforge-extractors 1.5.3 → 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/package.json +1 -1
- package/src/connectors/ats.js +19 -5
- package/src/embeddedState.js +48 -0
- package/src/templates.js +72 -10
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/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/templates.js
CHANGED
|
@@ -47,6 +47,7 @@ import { load } from 'cheerio';
|
|
|
47
47
|
// have made one file the whole package.
|
|
48
48
|
import { ATS_TEMPLATES } from './connectors/ats.js';
|
|
49
49
|
import { GOV_TEMPLATES } from './connectors/gov.js';
|
|
50
|
+
import { extractApolloTransport } from './embeddedState.js';
|
|
50
51
|
import { safeHref } from './urls.js';
|
|
51
52
|
|
|
52
53
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -543,6 +544,16 @@ export const TEMPLATES = [
|
|
|
543
544
|
description: 'Scrape an Amazon product page for title, price, rating, reviews, ASIN, and description.',
|
|
544
545
|
targetPattern: /amazon\.(com|co\.uk|de|fr|jp|ca|com\.au)/i,
|
|
545
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
|
+
}
|
|
546
557
|
const bullets = $('#feature-bullets ul li span.a-list-item')
|
|
547
558
|
.map((_, el) => tidy($(el).text()))
|
|
548
559
|
.get()
|
|
@@ -734,17 +745,50 @@ export const TEMPLATES = [
|
|
|
734
745
|
{
|
|
735
746
|
id: 'producthunt-launch',
|
|
736
747
|
name: 'Product Hunt Launch',
|
|
737
|
-
description:
|
|
738
|
-
|
|
739
|
-
|
|
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
|
+
|
|
740
780
|
return {
|
|
741
|
-
name:
|
|
742
|
-
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,
|
|
743
784
|
image: attr($, 'meta[property="og:image"]', 'content'),
|
|
744
|
-
url: safeHref(attr($, 'meta[property="og:url"]', 'content')),
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|
748
792
|
};
|
|
749
793
|
}
|
|
750
794
|
},
|
|
@@ -1030,6 +1074,21 @@ export class TemplateRegistry {
|
|
|
1030
1074
|
? template.extractRaw(body, url)
|
|
1031
1075
|
: template.extract(load(body));
|
|
1032
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
|
+
|
|
1033
1092
|
return {
|
|
1034
1093
|
template: id,
|
|
1035
1094
|
template_name: template.name,
|
|
@@ -1069,7 +1128,10 @@ export class TemplateRegistry {
|
|
|
1069
1128
|
template_name: template.name,
|
|
1070
1129
|
...(url !== undefined ? { url } : {}),
|
|
1071
1130
|
...(params !== undefined ? { params } : {}),
|
|
1072
|
-
|
|
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),
|
|
1073
1135
|
extractedAt: new Date().toISOString()
|
|
1074
1136
|
};
|
|
1075
1137
|
}
|