crawlforge-extractors 1.5.3 → 1.6.1
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 +111 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
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 ──────────────────────────────────────────────────────────────────
|
|
@@ -273,6 +274,35 @@ function amazonByline($) {
|
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
/** "4.7 out of 5 stars" → 4.7 */
|
|
277
|
+
/**
|
|
278
|
+
* ISO 4217 code for an Amazon price string. The add-to-cart form's hidden
|
|
279
|
+
* currencyCode input is absent on pages Amazon serves without a buy box
|
|
280
|
+
* (amazon.de and amazon.in book pages, R14 2026-09-03), so the code is read
|
|
281
|
+
* off the price itself: an explicit code ("52,02USD"), else the symbol. A
|
|
282
|
+
* bare "$" belongs to several marketplaces, told apart by the canonical host.
|
|
283
|
+
*/
|
|
284
|
+
function amazonCurrency($, price) {
|
|
285
|
+
if (!price) return null;
|
|
286
|
+
const code = price.match(/(USD|EUR|GBP|INR|JPY|CAD|AUD|MXN|BRL|SGD|AED|SAR|PLN|TRY)(?![A-Z])/);
|
|
287
|
+
if (code) return code[1];
|
|
288
|
+
if (price.includes('₹')) return 'INR';
|
|
289
|
+
if (price.includes('€')) return 'EUR';
|
|
290
|
+
if (price.includes('£')) return 'GBP';
|
|
291
|
+
if (price.includes('¥') || price.includes('¥')) return 'JPY';
|
|
292
|
+
if (price.includes('R$')) return 'BRL';
|
|
293
|
+
if (price.includes('zł')) return 'PLN';
|
|
294
|
+
if (price.includes('₺')) return 'TRY';
|
|
295
|
+
if (price.includes('$')) {
|
|
296
|
+
const host = (attr($, 'link[rel="canonical"]', 'href') || '').match(/^https?:\/\/([^/]+)/)?.[1] || '';
|
|
297
|
+
if (/\.ca$/.test(host)) return 'CAD';
|
|
298
|
+
if (/\.com\.au$/.test(host)) return 'AUD';
|
|
299
|
+
if (/\.com\.mx$/.test(host)) return 'MXN';
|
|
300
|
+
if (/\.sg$/.test(host)) return 'SGD';
|
|
301
|
+
return 'USD';
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
276
306
|
function amazonRating(value) {
|
|
277
307
|
const match = tidy(value)?.match(/([\d.]+)/);
|
|
278
308
|
return match ? Number.parseFloat(match[1]) : null;
|
|
@@ -543,6 +573,16 @@ export const TEMPLATES = [
|
|
|
543
573
|
description: 'Scrape an Amazon product page for title, price, rating, reviews, ASIN, and description.',
|
|
544
574
|
targetPattern: /amazon\.(com|co\.uk|de|fr|jp|ca|com\.au)/i,
|
|
545
575
|
extract($) {
|
|
576
|
+
// Amazon serves its robot check as HTTP 200: a "Continue shopping" page
|
|
577
|
+
// whose only form posts to /errors/validateCaptcha. Every selector below
|
|
578
|
+
// misses on it, which used to come back as a silent all-null record
|
|
579
|
+
// (amazon.es, observed 2026-09-01) — name the block instead.
|
|
580
|
+
if ($('form[action*="validateCaptcha"]').length > 0) {
|
|
581
|
+
throw new Error(
|
|
582
|
+
'Amazon answered with a captcha interstitial (an HTTP 200 robot check), not the product page. ' +
|
|
583
|
+
'The product data is not in this response — retry later or from a different IP.'
|
|
584
|
+
);
|
|
585
|
+
}
|
|
546
586
|
const bullets = $('#feature-bullets ul li span.a-list-item')
|
|
547
587
|
.map((_, el) => tidy($(el).text()))
|
|
548
588
|
.get()
|
|
@@ -551,12 +591,17 @@ export const TEMPLATES = [
|
|
|
551
591
|
.map(fullSizeImage)
|
|
552
592
|
.filter(Boolean);
|
|
553
593
|
|
|
594
|
+
const price = text($, '.a-price .a-offscreen') || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice');
|
|
595
|
+
|
|
554
596
|
return {
|
|
555
597
|
title: tidy(text($, '#productTitle')),
|
|
556
|
-
price
|
|
598
|
+
price,
|
|
557
599
|
// Amazon ships no priceCurrency meta tag — the ISO code is a hidden
|
|
558
|
-
// field on the add-to-cart form.
|
|
559
|
-
currency:
|
|
600
|
+
// field on the add-to-cart form, and off the price where there is none.
|
|
601
|
+
currency:
|
|
602
|
+
attr($, 'input[name*="currencyCode"]', 'value') ||
|
|
603
|
+
attr($, 'meta[itemprop="priceCurrency"]', 'content') ||
|
|
604
|
+
amazonCurrency($, price),
|
|
560
605
|
rating: amazonRating(attr($, '#acrPopover', 'title') || text($, '#averageCustomerReviews .a-icon-alt')),
|
|
561
606
|
review_count: amazonCount(text($, '#acrCustomerReviewText') || text($, '[data-hook="total-review-count"]')),
|
|
562
607
|
asin: text($, 'input#ASIN') || attr($, 'input[name="ASIN"]', 'value'),
|
|
@@ -718,7 +763,8 @@ export const TEMPLATES = [
|
|
|
718
763
|
title: $titleLink.text().trim(),
|
|
719
764
|
url: safeHref($titleLink.attr('href')),
|
|
720
765
|
site: $row.find('.sitebit a').text().trim() || null,
|
|
721
|
-
|
|
766
|
+
// "1 point" on a fresh story and "3 points" on the rest — strip both.
|
|
767
|
+
score: $score.text().replace(/\s*points?$/, '').trim() || null,
|
|
722
768
|
author: $subtext.find('.hnuser').text().trim() || null,
|
|
723
769
|
// ".age a" wraps the relative age string ("3 hours ago"); its href is the item permalink.
|
|
724
770
|
posted: $subtext.find('.age a').text().trim() || null,
|
|
@@ -734,17 +780,50 @@ export const TEMPLATES = [
|
|
|
734
780
|
{
|
|
735
781
|
id: 'producthunt-launch',
|
|
736
782
|
name: 'Product Hunt Launch',
|
|
737
|
-
description:
|
|
738
|
-
|
|
739
|
-
|
|
783
|
+
description:
|
|
784
|
+
'Scrape a Product Hunt product page for name, tagline, description, categories, website, ' +
|
|
785
|
+
'and follower/review counts. Product Hunt folded /posts/* launch pages into /products/* ' +
|
|
786
|
+
'product hubs, which carry no product-level vote count — followers and reviews are the ' +
|
|
787
|
+
"page's engagement numbers now.",
|
|
788
|
+
targetPattern: /producthunt\.com\/(posts|products)\//i,
|
|
789
|
+
extractRaw(body, url) {
|
|
790
|
+
// The RSC flight stream on these pages is a near-empty shell; the data
|
|
791
|
+
// the UI renders from — the GraphQL Product record — rides in Apollo's
|
|
792
|
+
// streaming-SSR transport pushes instead.
|
|
793
|
+
const products = [];
|
|
794
|
+
const walk = (node) => {
|
|
795
|
+
if (!node || typeof node !== 'object') return;
|
|
796
|
+
if (node.__typename === 'Product') products.push(node);
|
|
797
|
+
for (const value of Object.values(node)) walk(value);
|
|
798
|
+
};
|
|
799
|
+
for (const push of extractApolloTransport(body)) walk(push);
|
|
800
|
+
// The transport carries several Product objects (latestLaunch.product,
|
|
801
|
+
// forum subjects); the page's own is the one with the page-level fields.
|
|
802
|
+
const product =
|
|
803
|
+
products.find(p => 'followersCount' in p) || products.find(p => 'websiteUrl' in p) || null;
|
|
804
|
+
|
|
805
|
+
const $ = load(body);
|
|
806
|
+
const metaName = attr($, 'meta[property="og:title"]', 'content');
|
|
807
|
+
const categories = Array.isArray(product?.categories)
|
|
808
|
+
? product.categories.map(c => c?.name).filter(Boolean)
|
|
809
|
+
: null;
|
|
810
|
+
// The DOM fallback's href carries PH's ?ref=producthunt tracking param.
|
|
811
|
+
const domWebsite = (attr($, 'a[data-test="visit-website-button"]', 'href') || '')
|
|
812
|
+
.replace(/([?&])ref=producthunt(?=&|$)/, '$1')
|
|
813
|
+
.replace(/[?&]$/, '') || null;
|
|
814
|
+
|
|
740
815
|
return {
|
|
741
|
-
name:
|
|
742
|
-
tagline: attr($, 'meta[property="og:description"]', 'content'),
|
|
816
|
+
name: product?.name || (metaName ? metaName.replace(/\s*\|\s*Product Hunt\s*$/i, '') : null),
|
|
817
|
+
tagline: product?.tagline || attr($, 'meta[property="og:description"]', 'content'),
|
|
818
|
+
description: product?.description ?? null,
|
|
743
819
|
image: attr($, 'meta[property="og:image"]', 'content'),
|
|
744
|
-
url: safeHref(attr($, 'meta[property="og:url"]', 'content')),
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
820
|
+
url: safeHref(attr($, 'meta[property="og:url"]', 'content')) || url,
|
|
821
|
+
website: safeHref(product?.websiteUrl || domWebsite),
|
|
822
|
+
// null = the data layer was missing, [] = present with no categories.
|
|
823
|
+
topics: categories,
|
|
824
|
+
followers: product?.followersCount ?? null,
|
|
825
|
+
reviews_count: product?.reviewsCount ?? null,
|
|
826
|
+
reviews_rating: product?.reviewsRating ?? null
|
|
748
827
|
};
|
|
749
828
|
}
|
|
750
829
|
},
|
|
@@ -1030,6 +1109,21 @@ export class TemplateRegistry {
|
|
|
1030
1109
|
? template.extractRaw(body, url)
|
|
1031
1110
|
: template.extract(load(body));
|
|
1032
1111
|
|
|
1112
|
+
// A record with literally every field empty is not a page with no data —
|
|
1113
|
+
// it is a page the template's selectors all missed: an interstitial
|
|
1114
|
+
// (captcha, consent wall, bot check) served as HTTP 200, or a layout
|
|
1115
|
+
// change. Reporting it as success is how amazon.es's captcha page came
|
|
1116
|
+
// back as a clean all-null product (2026-09-01). Fail loudly instead.
|
|
1117
|
+
const values = data && typeof data === 'object' && !Array.isArray(data) ? Object.values(data) : [];
|
|
1118
|
+
const isEmpty = (v) => v == null || v === '' || (Array.isArray(v) && v.length === 0);
|
|
1119
|
+
if (values.length > 0 && values.every(isEmpty)) {
|
|
1120
|
+
throw new Error(
|
|
1121
|
+
`Template "${id}" matched the page but extracted no data — every field came back empty. ` +
|
|
1122
|
+
'The server likely answered with an interstitial (captcha, consent or bot wall) or the site changed ' +
|
|
1123
|
+
'its layout. This is an extraction failure, not a page with nothing on it.'
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1033
1127
|
return {
|
|
1034
1128
|
template: id,
|
|
1035
1129
|
template_name: template.name,
|
|
@@ -1069,7 +1163,10 @@ export class TemplateRegistry {
|
|
|
1069
1163
|
template_name: template.name,
|
|
1070
1164
|
...(url !== undefined ? { url } : {}),
|
|
1071
1165
|
...(params !== undefined ? { params } : {}),
|
|
1072
|
-
|
|
1166
|
+
// params ride along for connectors whose upstream API has no query
|
|
1167
|
+
// switch for an option (Ashby's descriptions opt-in trims at extract
|
|
1168
|
+
// time; Greenhouse/Workable put theirs in the URL instead).
|
|
1169
|
+
data: template.extractList(body, url, params),
|
|
1073
1170
|
extractedAt: new Date().toISOString()
|
|
1074
1171
|
};
|
|
1075
1172
|
}
|